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.
Quick Start: Scaffolding Plugins with CLI
The easiest way to create a new plugin is using the elizaOS CLI, which provides interactive scaffolding with pre-configured templates.
Using elizaos create
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 plugin
When 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
Quick Plugin Structure
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
Full Plugin Structure
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]
After Scaffolding
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 build
The 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
The CLI templates follow all elizaOS conventions and best practices, making it easy to get started without worrying about configuration.
Manual Plugin Creation
If you prefer to create a plugin manually or need custom configuration:
1. Initialize the Project
mkdir plugin-my-custom
cd plugin-my-custom
bun init
2. Install Dependencies
# Core dependency
bun add @elizaos/core
# Development dependencies
bun add -d typescript tsup @types/node
Create 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'],
});
5. Create Plugin Structure
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;
6. Update package.json
{
"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"
}
}
Using Your Plugin in Projects
Option 1: Plugin Inside the Monorepo
If developing within the elizaOS monorepo:
- Add your plugin to the root
package.json
as a workspace dependency:
{
"dependencies": {
"@yourorg/plugin-myplugin": "workspace:*"
}
}
-
Run
bun install
in the root directory
-
Use the plugin in your project:
import { myPlugin } from '@yourorg/plugin-myplugin';
const agent = {
name: 'MyAgent',
plugins: [myPlugin],
};
Option 2: Plugin Outside the Monorepo
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"
}
}
When using bun link
, remember to rebuild your plugin (bun run build
) after making changes for them to be reflected in your project.
Testing Plugins
Test Environment Setup
Directory Structure
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
Base Test Imports
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';
Creating Test Utilities
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;
}
Testing Actions
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');
}
}
});
});
});
Testing Providers
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();
});
});
Testing Services
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
});
});
E2E Testing
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);
},
},
],
};
Running Tests
# 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 Best Practices
- 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
Development Workflow
1. Development Mode
# Watch mode with hot reloading
bun run dev
# Or with elizaOS CLI
elizaos dev
2. Building for Production
# Build the plugin
bun run build
# Output will be in dist/
3. Publishing
To npm
# Login to npm
npm login
# Publish
npm publish --access public
To GitHub Packages
Update package.json
:
{
"name": "@yourorg/plugin-name",
"publishConfig": {
"registry": "https://npm.pkg.github.com"
}
}
Then publish:
4. Version Management
# 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.0
Debugging
Enable Debug Logging
import { logger } from '@elizaos/core';
// In your plugin
logger.debug('Plugin initialized', { config });
logger.info('Action executed', { result });
logger.error('Failed to connect', { error });
VS Code Debug Configuration
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"
}
]
}
Common Issues and Solutions
Issue: Plugin not loading
Solution: Check that your plugin is properly exported and added to the agent’s plugin array.
Issue: TypeScript errors
Solution: Ensure @elizaos/core
is installed and TypeScript is configured correctly.
Issue: Service not available
Solution: Verify the service is registered in the plugin and started properly.
Issue: Tests failing with module errors
Solution: Make sure your tsconfig.json
has proper module resolution settings for Bun.
See Also