Plugin Development Guide
Overview
Ever Works has a modular plugin system that allows extending the platform with new AI providers, search engines, content extractors, screenshot services, Git integrations, deploy targets, and pipeline steps. Each plugin is a standalone ESM package that declares its metadata in package.json, extends a base class from @ever-works/plugin, and defines user-configurable settings via JSON Schema.
This guide walks through creating a new plugin from scratch, using the actual platform plugin infrastructure.
Architecture
Source Files
| File | Purpose |
|---|---|
packages/agent/src/plugins/plugins.module.ts | Global plugin module with forRoot() / forRootAsync() |
packages/agent/src/plugins/services/plugin-bootstrap.service.ts | Entry point: discover, load, call onLoad |
packages/agent/src/plugins/services/plugin-loader.service.ts | File system scanning, manifest extraction, module loading |
packages/agent/src/plugins/services/plugin-registry.service.ts | In-memory registry with category/capability indexes |
packages/agent/src/plugins/services/plugin-manifest-validator.service.ts | Validates everworks.plugin manifest in package.json |
packages/agent/src/plugins/services/plugin-class-validator.service.ts | Validates plugin class implements required interface |
packages/agent/src/plugins/services/plugin-version-checker.service.ts | Checks version compatibility with platform |
packages/agent/src/plugins/services/plugin-lifecycle-manager.service.ts | Calls onLoad, onUnload lifecycle hooks |
packages/agent/src/plugins/services/plugin-settings.service.ts | 4-level settings resolution |
packages/agent/src/plugins/services/plugin-context-factory.service.ts | Creates execution context for plugins |
packages/agent/src/plugins/entities/plugin.entity.ts | Database entity for installed plugins |
packages/agent/src/plugins/entities/user-plugin.entity.ts | Per-user plugin settings entity |
packages/agent/src/plugins/entities/work-plugin.entity.ts | Per-work plugin settings entity |
packages/plugins/*/ | 21 built-in plugin implementations |
Step-by-Step: Creating a New AI Provider Plugin
Step 1: Scaffold the Package
Create a new work in packages/plugins/:
packages/plugins/my-provider/
package.json
src/
my-provider.ts
my-provider.spec.ts
tsconfig.json
tsup.config.ts
vitest.config.ts
Step 2: Define the Manifest in package.json
The everworks.plugin key in package.json is the plugin manifest. The PluginManifestValidatorService extracts and validates this during discovery:
{
"name": "@ever-works/plugin-my-provider",
"version": "1.0.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"everworks": {
"plugin": {
"id": "my-provider",
"name": "My AI Provider",
"version": "1.0.0",
"description": "Custom AI provider integration",
"category": "ai-provider",
"capabilities": ["ai-provider"],
"builtIn": true,
"autoEnable": false,
"settingsSchema": {
"type": "object",
"properties": {
"apiKey": {
"type": "string",
"title": "API Key",
"x-secret": true,
"x-envVar": "MY_PROVIDER_API_KEY",
"x-widget": "password"
},
"defaultModel": {
"type": "string",
"title": "Default Model",
"default": "my-model-v1"
},
"simpleModel": {
"type": "string",
"title": "Simple Task Model"
},
"mediumModel": {
"type": "string",
"title": "Medium Task Model"
},
"complexModel": {
"type": "string",
"title": "Complex Task Model"
}
},
"required": ["apiKey"]
}
}
},
"dependencies": {
"@ever-works/plugin": "workspace:*"
},
"devDependencies": {
"tsup": "^8.0.0",
"vitest": "^2.0.0"
}
}
Step 3: Implement the Plugin Class
Extend BaseAiProvider from @ever-works/plugin/abstract:
import { BaseAiProvider } from '@ever-works/plugin/abstract';
import { AiOperations } from '@ever-works/plugin/ai';
import type {
ChatCompletionOptions,
ChatCompletionResponse,
ChatCompletionChunk,
AiModel,
AskJsonCompletionResponse,
PluginManifest
} from '@ever-works/plugin';
export default class MyProviderPlugin extends BaseAiProvider {
id = 'my-provider';
name = 'My AI Provider';
version = '1.0.0';
category = 'ai-provider' as const;
capabilities = ['ai-provider'];
providerName = 'MyProvider';
async isAvailable(settings?: Record<string, unknown>): Promise<boolean> {
const apiKey = settings?.apiKey as string;
return !!apiKey;
}
async createChatCompletion(options: ChatCompletionOptions): Promise<ChatCompletionResponse> {
const ops = new AiOperations({
provider: 'openai-compatible',
apiKey: options.settings?.apiKey as string,
baseUrl: 'https://api.myprovider.com/v1',
model: options.model || 'my-model-v1'
});
return ops.chatCompletion(options);
}
async *createStreamingChatCompletion(options: ChatCompletionOptions): AsyncGenerator<ChatCompletionChunk> {
const ops = new AiOperations({
provider: 'openai-compatible',
apiKey: options.settings?.apiKey as string,
baseUrl: 'https://api.myprovider.com/v1',
model: options.model || 'my-model-v1'
});
yield* ops.streamingChatCompletion(options);
}
async listModels(settings?: Record<string, unknown>): Promise<readonly AiModel[]> {
return [
{
id: 'my-model-v1',
name: 'My Model V1',
contextLength: 128000,
inputCostPer1k: 0.001,
outputCostPer1k: 0.002
}
];
}
}
Step 4: Build Configuration
tsup.config.ts:
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/my-provider.ts'],
format: ['esm'],
dts: true,
clean: true
});
Step 5: Write Tests
import { describe, it, expect } from 'vitest';
import MyProviderPlugin from './my-provider';
describe('MyProviderPlugin', () => {
const plugin = new MyProviderPlugin();
it('should have correct metadata', () => {
expect(plugin.id).toBe('my-provider');
expect(plugin.capabilities).toContain('ai-provider');
});
it('should check availability', async () => {
expect(await plugin.isAvailable({})).toBe(false);
expect(await plugin.isAvailable({ apiKey: 'test' })).toBe(true);
});
});
Plugin Categories
| Category | Capability | Base Class | Examples |
|---|---|---|---|
ai-provider | ai-provider | BaseAiProvider | openai, anthropic, google, groq, ollama |
search | search | -- | exa, tavily, serpapi, brave |
content-extraction | content-extraction | -- | local-content-extractor, notion-extractor |
screenshot | screenshot | -- | screenshotone, urlbox |
git | git | -- | github |
infrastructure | deploy | -- | vercel, apify |
pipeline | pipeline | -- | standard-pipeline, agent-pipeline |
ai-tools | varies | -- | claude-code, vercel-ai-gateway |
Plugin Enable/Disable Resolution
The resolvePluginEnabled() function determines whether a plugin is active for a given scope:
1. System plugins -> always enabled
2. User-level DISABLE -> cascades globally (plugin off everywhere)
3. Work explicit record -> use its enabled value
4. User autoEnableForWorks -> true (in work context)
5. User record exists -> enabled outside work
6. Fallback to manifest autoEnable (default false)
Operations the platform may call by name
The platform's execution router can call a plugin method by name: in the API process for a short call, or in the run-plugin-operation worker job for a long one. Only methods the manifest lists in everworks.plugin.operations can be called that way. Anything else the class defines, including inherited helpers and methods TypeScript marks private or protected, is refused with OPERATION_NOT_FOUND. Those markers are erased at runtime, so the list is the only boundary.
"everworks": {
"plugin": {
"id": "my-agent",
"executionProfile": "sync",
"operations": [
{ "name": "runSandboxSession", "executionProfile": "long-running" },
{ "name": "listModels" }
]
}
}
- Omitted or empty: nothing can be called by name. Capability calls through the facades are unaffected.
name: the method name. It uses letters and digits, optionally dot-separated, with no leading_or$. These names are reserved and can never be listed:constructor,onLoad,onUnload,onEnable,onDisable,healthCheck,getManifest,getSettings,validateSettings,validateConnection,configure,initializeanddispose.executionProfile(optional, per operation):syncorlong-running. It overrides the manifest-levelexecutionProfilefor that operation. Along-runningoperation runs in the worker, in bundled mode too.
Declare operations (and executionProfile) in package.json. A lazily loaded plugin is routed before it loads, so these two fields are never taken from what getManifest() returns.
A malformed list fails manifest validation, and the plugin is not loaded at all: a bad or reserved name, a duplicate, or an unknown profile. Discovery logs the reason ("Skipping plugin package … invalid manifest").
The method is called with one argument, the call's args object. On the long-running path, its result must be serialisable.
Settings JSON Schema Extensions
| Extension | Purpose | Example |
|---|---|---|
x-secret | Marks field as sensitive (encrypted at rest) | "x-secret": true |
x-envVar | Environment variable fallback | "x-envVar": "OPENAI_API_KEY" |
x-widget | UI widget hint | "x-widget": "password" |
Best Practices
-
Use
AiOperationsfrom@ever-works/plugin/ai-- this wraps LangChain and provides a consistent interface across all AI providers. -
Declare settings schema -- every configurable value should be in the
settingsSchemaso the UI can generate settings forms. -
Mark API keys with
x-secret-- secrets are encrypted in the database and never exposed in API responses. -
Provide
x-envVarfallbacks -- allow server admins to set credentials via environment variables instead of the UI. -
Implement
isAvailable()-- the facade checks availability before routing requests; returnfalseif required settings are missing. -
Support model routing -- define
simpleModel,mediumModel, andcomplexModelsettings for complexity-based routing. -
Build with tsup, test with Vitest -- follow the standard plugin toolchain for consistency.
-
Export as default -- the
PluginLoaderServicelooks formodule.defaultfirst when loading plugins.