Skip to main content

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​

FilePurpose
packages/agent/src/plugins/plugins.module.tsGlobal plugin module with forRoot() / forRootAsync()
packages/agent/src/plugins/services/plugin-bootstrap.service.tsEntry point: discover, load, call onLoad
packages/agent/src/plugins/services/plugin-loader.service.tsFile system scanning, manifest extraction, module loading
packages/agent/src/plugins/services/plugin-registry.service.tsIn-memory registry with category/capability indexes
packages/agent/src/plugins/services/plugin-manifest-validator.service.tsValidates everworks.plugin manifest in package.json
packages/agent/src/plugins/services/plugin-class-validator.service.tsValidates plugin class implements required interface
packages/agent/src/plugins/services/plugin-version-checker.service.tsChecks version compatibility with platform
packages/agent/src/plugins/services/plugin-lifecycle-manager.service.tsCalls onLoad, onUnload lifecycle hooks
packages/agent/src/plugins/services/plugin-settings.service.ts4-level settings resolution
packages/agent/src/plugins/services/plugin-context-factory.service.tsCreates execution context for plugins
packages/agent/src/plugins/entities/plugin.entity.tsDatabase entity for installed plugins
packages/agent/src/plugins/entities/user-plugin.entity.tsPer-user plugin settings entity
packages/agent/src/plugins/entities/work-plugin.entity.tsPer-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​

CategoryCapabilityBase ClassExamples
ai-providerai-providerBaseAiProvideropenai, anthropic, google, groq, ollama
searchsearch--exa, tavily, serpapi, brave
content-extractioncontent-extraction--local-content-extractor, notion-extractor
screenshotscreenshot--screenshotone, urlbox
gitgit--github
infrastructuredeploy--vercel, apify
pipelinepipeline--standard-pipeline, agent-pipeline
ai-toolsvaries--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, initialize and dispose.
  • executionProfile (optional, per operation): sync or long-running. It overrides the manifest-level executionProfile for that operation. A long-running operation 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​

ExtensionPurposeExample
x-secretMarks field as sensitive (encrypted at rest)"x-secret": true
x-envVarEnvironment variable fallback"x-envVar": "OPENAI_API_KEY"
x-widgetUI widget hint"x-widget": "password"

Best Practices​

  1. Use AiOperations from @ever-works/plugin/ai -- this wraps LangChain and provides a consistent interface across all AI providers.

  2. Declare settings schema -- every configurable value should be in the settingsSchema so the UI can generate settings forms.

  3. Mark API keys with x-secret -- secrets are encrypted in the database and never exposed in API responses.

  4. Provide x-envVar fallbacks -- allow server admins to set credentials via environment variables instead of the UI.

  5. Implement isAvailable() -- the facade checks availability before routing requests; return false if required settings are missing.

  6. Support model routing -- define simpleModel, mediumModel, and complexModel settings for complexity-based routing.

  7. Build with tsup, test with Vitest -- follow the standard plugin toolchain for consistency.

  8. Export as default -- the PluginLoaderService looks for module.default first when loading plugins.