| title | description |
|---|---|
Development |
Creating, building, and testing elizaOS plugins |
This guide covers all aspects of plugin development in the elizaOS system, from scaffolding to testing.
This guide uses `bun` as the package manager, which is the preferred tool for elizaOS development. Bun provides faster installation times and built-in TypeScript support.The easiest way to create a new plugin is using the elizaOS CLI, which provides interactive scaffolding with pre-configured templates.
The CLI offers two plugin templates to get you started quickly:
# Interactive plugin creation
elizaos create
# Or specify the name directly
elizaos create my-plugin --type pluginWhen creating a plugin, you'll be prompted to choose between:
-
Quick Plugin (Backend Only) - Simple backend-only plugin without frontend
- Perfect for: API integrations, blockchain actions, data providers
- Includes: Basic plugin structure, actions, providers, services
- No frontend components or UI routes
-
Full Plugin (with Frontend) - Complete plugin with React frontend and API routes
- Perfect for: Plugins that need web UI, dashboards, or visual components
- Includes: Everything from Quick Plugin + React frontend, Vite setup, API routes
- Tailwind CSS pre-configured for styling
After running elizaos create and selecting "Quick Plugin", you'll get:
plugin-my-plugin/
├── src/
│ ├── index.ts # Plugin manifest
│ ├── actions/ # Your agent actions
│ │ └── example.ts
│ ├── providers/ # Context providers
│ │ └── example.ts
│ └── types/ # TypeScript types
│ └── index.ts
├── package.json # Pre-configured with elizaos deps
├── tsconfig.json # TypeScript config
├── tsup.config.ts # Build configuration
└── README.md # Plugin documentation
Selecting "Full Plugin" adds frontend capabilities:
plugin-my-plugin/
├── src/
│ ├── index.ts # Plugin manifest with routes
│ ├── actions/
│ ├── providers/
│ ├── types/
│ └── frontend/ # React frontend
│ ├── App.tsx
│ ├── main.tsx
│ └── components/
├── public/ # Static assets
├── index.html # Frontend entry
├── vite.config.ts # Vite configuration
├── tailwind.config.js # Tailwind setup
└── [other config files]
Once your plugin is created:
# Navigate to your plugin
cd plugin-my-plugin
# Install dependencies (automatically done by CLI)
bun install
# Start development mode with hot reloading
elizaos dev
# Or start in production mode
elizaos start
# Build your plugin for distribution
bun run buildThe scaffolded plugin includes:
- ✅ Proper TypeScript configuration
- ✅ Build setup with tsup (and Vite for full plugins)
- ✅ Example action and provider to extend
- ✅ Integration with
@elizaos/core - ✅ Development scripts ready to use
- ✅ Basic tests structure
If you prefer to create a plugin manually or need custom configuration:
mkdir plugin-my-custom
cd plugin-my-custom
bun init# Core dependency
bun add @elizaos/core
# Development dependencies
bun add -d typescript tsup @types/nodeCreate tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"rootDir": "./src",
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}Create tsup.config.ts:
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
splitting: false,
sourcemap: true,
clean: true,
external: ['@elizaos/core'],
});Create src/index.ts:
import type { Plugin } from '@elizaos/core';
import { myAction } from './actions/myAction';
import { myProvider } from './providers/myProvider';
import { MyService } from './services/myService';
export const myPlugin: Plugin = {
name: 'my-custom-plugin',
description: 'A custom plugin for elizaOS',
actions: [myAction],
providers: [myProvider],
services: [MyService],
init: async (config, runtime) => {
console.log('Plugin initialized');
}
};
export default myPlugin;{
"name": "@myorg/plugin-custom",
"version": "0.1.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"test": "bun test"
}
}If developing within the elizaOS monorepo:
- Add your plugin to the root
package.jsonas a workspace dependency:
{
"dependencies": {
"@yourorg/plugin-myplugin": "workspace:*"
}
}-
Run
bun installin the root directory -
Use the plugin in your project:
import { myPlugin } from '@yourorg/plugin-myplugin';
const agent = {
name: 'MyAgent',
plugins: [myPlugin],
};For plugins outside the elizaOS monorepo:
- In your plugin directory, build and link it:
# In your plugin directory
bun install
bun run build
bun link- In your project directory, link the plugin:
# In your project directory
cd packages/project-starter
bun link @yourorg/plugin-myplugin- Add to your project's
package.json:
{
"dependencies": {
"@yourorg/plugin-myplugin": "link:@yourorg/plugin-myplugin"
}
}src/
__tests__/
test-utils.ts # Shared test utilities and mocks
index.test.ts # Main plugin tests
actions.test.ts # Action tests
providers.test.ts # Provider tests
evaluators.test.ts # Evaluator tests
services.test.ts # Service tests
actions/
providers/
evaluators/
services/
index.ts
import { describe, expect, it, mock, beforeEach, afterEach, spyOn } from 'bun:test';
import {
type IAgentRuntime,
type Memory,
type State,
type HandlerCallback,
type Action,
type Provider,
type Evaluator,
ModelType,
logger,
} from '@elizaos/core';Create a test-utils.ts file with reusable mocks:
import { mock } from 'bun:test';
import {
type IAgentRuntime,
type Memory,
type State,
type Character,
type UUID,
} from '@elizaos/core';
// Mock Runtime Type
export type MockRuntime = Partial<IAgentRuntime> & {
agentId: UUID;
character: Character;
getSetting: ReturnType<typeof mock>;
useModel: ReturnType<typeof mock>;
composeState: ReturnType<typeof mock>;
createMemory: ReturnType<typeof mock>;
getMemories: ReturnType<typeof mock>;
getService: ReturnType<typeof mock>;
};
// Create Mock Runtime
export function createMockRuntime(overrides?: Partial<MockRuntime>): MockRuntime {
return {
agentId: 'test-agent-123' as UUID,
character: {
name: 'TestAgent',
bio: 'A test agent',
id: 'test-character' as UUID,
...overrides?.character,
},
getSetting: mock((key: string) => {
const settings: Record<string, string> = {
TEST_API_KEY: 'test-key-123',
...overrides?.settings,
};
return settings[key];
}),
useModel: mock(async () => ({
content: 'Mock response from LLM',
success: true,
})),
composeState: mock(async () => ({
values: { test: 'state' },
data: {},
text: 'Composed state',
})),
createMemory: mock(async () => ({ id: 'memory-123' })),
getMemories: mock(async () => []),
getService: mock(() => null),
...overrides,
};
}
// Create Mock Message
export function createMockMessage(overrides?: Partial<Memory>): Memory {
return {
id: 'msg-123' as UUID,
entityId: 'entity-123' as UUID,
roomId: 'room-123' as UUID,
content: {
text: 'Test message',
...overrides?.content,
},
...overrides,
} as Memory;
}
// Create Mock State
export function createMockState(overrides?: Partial<State>): State {
return {
values: {
test: 'value',
...overrides?.values,
},
data: overrides?.data || {},
text: overrides?.text || 'Test state',
} as State;
}import { describe, it, expect, beforeEach } from 'bun:test';
import { myAction } from '../src/actions/myAction';
import { createMockRuntime, createMockMessage, createMockState } from './test-utils';
import { ActionResult } from '@elizaos/core';
describe('MyAction', () => {
let mockRuntime: any;
let mockMessage: Memory;
let mockState: State;
beforeEach(() => {
mockRuntime = createMockRuntime({
settings: { MY_API_KEY: 'test-key' },
});
mockMessage = createMockMessage({ content: { text: 'Do the thing' } });
mockState = createMockState();
});
describe('validation', () => {
it('should validate when all requirements are met', async () => {
const isValid = await myAction.validate(mockRuntime, mockMessage, mockState);
expect(isValid).toBe(true);
});
it('should not validate without required service', async () => {
mockRuntime.getService = mock(() => null);
const isValid = await myAction.validate(mockRuntime, mockMessage, mockState);
expect(isValid).toBe(false);
});
});
describe('handler', () => {
it('should return success ActionResult on successful execution', async () => {
const mockCallback = mock();
const result = await myAction.handler(
mockRuntime,
mockMessage,
mockState,
{},
mockCallback
);
expect(result.success).toBe(true);
expect(result.text).toContain('completed');
expect(result.values).toHaveProperty('lastActionTime');
expect(mockCallback).toHaveBeenCalled();
});
it('should handle errors gracefully', async () => {
// Make service throw error
mockRuntime.getService = mock(() => {
throw new Error('Service unavailable');
});
const result = await myAction.handler(mockRuntime, mockMessage, mockState);
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
expect(result.text).toContain('Failed');
});
it('should access previous action results', async () => {
const previousResults: ActionResult[] = [
{
success: true,
values: { previousData: 'test' },
data: { actionName: 'PREVIOUS_ACTION' },
},
];
const result = await myAction.handler(
mockRuntime,
mockMessage,
mockState,
{ context: { previousResults } }
);
// Verify it used previous results
expect(result.values?.usedPreviousData).toBe(true);
});
});
describe('examples', () => {
it('should have valid example structure', () => {
expect(myAction.examples).toBeDefined();
expect(Array.isArray(myAction.examples)).toBe(true);
// Each example should be a conversation array
for (const example of myAction.examples!) {
expect(Array.isArray(example)).toBe(true);
// Each message should have name and content
for (const message of example) {
expect(message).toHaveProperty('name');
expect(message).toHaveProperty('content');
}
}
});
});
});import { describe, it, expect, beforeEach } from 'bun:test';
import { myProvider } from '../src/providers/myProvider';
import { createMockRuntime, createMockMessage, createMockState } from './test-utils';
describe('MyProvider', () => {
let mockRuntime: any;
let mockMessage: Memory;
let mockState: State;
beforeEach(() => {
mockRuntime = createMockRuntime();
mockMessage = createMockMessage();
mockState = createMockState();
});
it('should return provider result with text and data', async () => {
const result = await myProvider.get(mockRuntime, mockMessage, mockState);
expect(result).toBeDefined();
expect(result.text).toContain('Current');
expect(result.data).toBeDefined();
expect(result.values).toBeDefined();
});
it('should handle errors gracefully', async () => {
// Mock service to throw error
mockRuntime.getService = mock(() => {
throw new Error('Service error');
});
const result = await myProvider.get(mockRuntime, mockMessage, mockState);
expect(result.text).toContain('Unable');
expect(result.data?.error).toBeDefined();
});
});import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { MyService } from '../src/services/myService';
import { createMockRuntime } from './test-utils';
describe('MyService', () => {
let mockRuntime: any;
let service: MyService;
beforeEach(async () => {
mockRuntime = createMockRuntime({
settings: {
MY_API_KEY: 'test-api-key',
},
});
});
afterEach(async () => {
if (service) {
await service.stop();
}
});
it('should initialize successfully with valid config', async () => {
service = await MyService.start(mockRuntime);
expect(service).toBeDefined();
expect(service.capabilityDescription).toBeDefined();
});
it('should throw error without API key', async () => {
mockRuntime.getSetting = mock(() => undefined);
expect(async () => {
await MyService.start(mockRuntime);
}).toThrow('MY_API_KEY not configured');
});
it('should clean up resources on stop', async () => {
service = await MyService.start(mockRuntime);
await service.stop();
// Verify cleanup happened
});
});For integration testing with a live runtime:
// tests/e2e/myPlugin.e2e.ts
export const myPluginE2ETests = {
name: 'MyPlugin E2E Tests',
tests: [
{
name: 'should execute full plugin flow',
fn: async (runtime: IAgentRuntime) => {
// Create test message
const message: Memory = {
id: generateId(),
entityId: 'test-user',
roomId: runtime.agentId,
content: {
text: 'Please do the thing',
source: 'test',
},
};
// Store message
await runtime.createMemory(message, 'messages');
// Compose state
const state = await runtime.composeState(message);
// Execute action
const result = await myAction.handler(
runtime,
message,
state,
{},
async (response) => {
// Verify callback responses
expect(response.text).toBeDefined();
}
);
// Verify result
expect(result.success).toBe(true);
// Verify side effects
const memories = await runtime.getMemories({
roomId: message.roomId,
tableName: 'action_results',
count: 1,
});
expect(memories.length).toBeGreaterThan(0);
},
},
],
};# Run all tests
bun test
# Run specific test file
bun test src/__tests__/actions.test.ts
# Run with watch mode
bun test --watch
# Run with coverage
bun test --coverage- Test in Isolation: Use mocks to isolate components
- Test Happy Path and Errors: Cover both success and failure cases
- Test Validation Logic: Ensure actions validate correctly
- Test Examples: Verify example structures are valid
- Test Side Effects: Verify database writes, API calls, etc.
- Use Descriptive Names: Make test purposes clear
- Keep Tests Fast: Mock external dependencies
- Test Public API: Focus on what users interact with
# Watch mode with hot reloading
bun run dev
# Or with elizaOS CLI
elizaos dev# Build the plugin
bun run build
# Output will be in dist/# Login to npm
npm login
# Publish
npm publish --access publicUpdate package.json:
{
"name": "@yourorg/plugin-name",
"publishConfig": {
"registry": "https://npm.pkg.github.com"
}
}Then publish:
npm publish# Bump version
npm version patch # 0.1.0 -> 0.1.1
npm version minor # 0.1.0 -> 0.2.0
npm version major # 0.1.0 -> 1.0.0import { logger } from '@elizaos/core';
// In your plugin
logger.debug('Plugin initialized', { config });
logger.info('Action executed', { result });
logger.error('Failed to connect', { error });Create .vscode/launch.json:
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug Plugin",
"runtimeExecutable": "bun",
"program": "${workspaceFolder}/src/index.ts",
"cwd": "${workspaceFolder}",
"console": "integratedTerminal"
}
]
}Solution: Check that your plugin is properly exported and added to the agent's plugin array.
Solution: Ensure @elizaos/core is installed and TypeScript is configured correctly.
Solution: Verify the service is registered in the plugin and started properly.
Solution: Make sure your tsconfig.json has proper module resolution settings for Bun.