This comprehensive guide covers all aspects of plugin development in the ElizaOS system, consolidating patterns and best practices from platform plugins, LLM plugins, and DeFi plugins.
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.
Table of Contents
- Introduction
- Quick Start: Scaffolding Plugins with CLI
- Plugin Architecture Overview
- Core Plugin Components
- Advanced: Creating Plugins Manually
- Plugin Types and Patterns
- Advanced Configuration
- Testing Strategies
- Security Best Practices
- Publishing and Distribution
- Reference Examples
Introduction
ElizaOS plugins are modular extensions that enhance AI agents with new capabilities, integrations, and behaviors. The plugin system follows a consistent architecture that enables:
- Modularity: Add or remove functionality without modifying core code
- Reusability: Share plugins across different agents
- Type Safety: Full TypeScript support for robust development
- Flexibility: Support for various plugin types (platform, LLM, DeFi, etc.)
What Can Plugins Do?
- Platform Integrations: Connect to Discord, Telegram, Slack, Twitter, etc.
- LLM Providers: Integrate different AI models (OpenAI, Anthropic, Google, etc.)
- Blockchain/DeFi: Execute transactions, manage wallets, interact with smart contracts
- Data Sources: Connect to databases, APIs, or external services
- Custom Actions: Define new agent behaviors and capabilities
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. This is the recommended approach for most developers.
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.
Using Your Plugin in Projects
Plugins don’t necessarily need to be in the ElizaOS monorepo. You have two options for using your plugin in a project:
Option 1: Plugin Inside the Monorepo
If you’re developing your plugin within the ElizaOS monorepo (in the packages/
directory), you need to add it as a workspace dependency:
- Add your plugin to the root
package.json
as a workspace dependency:
{
"dependencies": {
"@elizaos/plugin-knowledge": "workspace:*",
"@yourorg/plugin-myplugin": "workspace:*"
}
}
-
Run bun install
in the root directory to link the workspace dependency
-
Use the plugin in your project:
import { myPlugin } from '@yourorg/plugin-myplugin';
const agent = {
name: 'MyAgent',
plugins: [myPlugin],
};
Option 2: Plugin Outside the Monorepo
If you’re creating a plugin outside of the ElizaOS monorepo (recommended for most users), use bun link
:
- In your plugin directory, build and link it:
# In your plugin directory (e.g., plugin-myplugin/)
bun install
bun run build
bun link
- In your project directory (e.g., using project-starter), link the plugin:
# In your project directory
cd packages/project-starter # or wherever your agent project is
bun link @yourorg/plugin-myplugin
- Add the plugin to your project’s
package.json
dependencies:
{
"dependencies": {
"@yourorg/plugin-myplugin": "link:@yourorg/plugin-myplugin"
}
}
- Use the plugin in your project:
import { myPlugin } from '@yourorg/plugin-myplugin';
const agent = {
name: 'MyAgent',
plugins: [myPlugin],
};
When using bun link
, remember to rebuild your plugin (bun run build
) after making changes for them to be reflected in your project.
Plugin Architecture Overview
Plugin Interface
Every plugin must implement the core Plugin
interface:
import type { Plugin } from '@elizaos/core';
export interface Plugin {
name: string;
description: string;
// Initialize plugin with runtime services
init?: (config: Record<string, string>, runtime: IAgentRuntime) => Promise<void>;
// Configuration
config?: { [key: string]: any };
// Services - Note: This is (typeof Service)[] not Service[]
services?: (typeof Service)[];
// Entity component definitions
componentTypes?: {
name: string;
schema: Record<string, unknown>;
validator?: (data: any) => boolean;
}[];
// Optional plugin features
actions?: Action[];
providers?: Provider[];
evaluators?: Evaluator[];
adapter?: IDatabaseAdapter;
models?: {
[key: string]: (...args: any[]) => Promise<any>;
};
events?: PluginEvents;
routes?: Route[];
tests?: TestSuite[];
// Dependencies
dependencies?: string[];
testDependencies?: string[];
// Plugin priority (higher priority plugins are loaded first)
priority?: number;
// Schema for validation
schema?: any;
}
Directory Structure
Standard plugin structure:
packages/plugin-<name>/
├── src/
│ ├── index.ts # Plugin manifest and exports
│ ├── service.ts # Main service implementation
│ ├── actions/ # Agent capabilities
│ │ └── *.ts
│ ├── providers/ # Context providers
│ │ └── *.ts
│ ├── evaluators/ # Post-processing
│ │ └── *.ts
│ ├── handlers/ # LLM model handlers
│ │ └── *.ts
│ ├── types/ # TypeScript definitions
│ │ └── index.ts
│ ├── constants/ # Configuration constants
│ │ └── index.ts
│ ├── utils/ # Helper functions
│ │ └── *.ts
│ └── tests.ts # Test suite
├── __tests__/ # Unit tests
├── package.json
├── tsconfig.json
├── tsup.config.ts
└── README.md
Core Plugin Components
1. Services
Services manage stateful connections and provide core functionality. They are singleton instances that persist throughout the agent’s lifecycle.
import { Service, IAgentRuntime, logger } from '@elizaos/core';
export class MyService extends Service {
static serviceType = 'my-service';
capabilityDescription = 'Description of what this service provides';
private client: any;
private refreshInterval: NodeJS.Timer | null = null;
constructor(protected runtime: IAgentRuntime) {
super();
}
static async start(runtime: IAgentRuntime): Promise<MyService> {
logger.info('Initializing MyService');
const service = new MyService(runtime);
// Initialize connections, clients, etc.
await service.initialize();
// Set up periodic tasks if needed
service.refreshInterval = setInterval(
() => service.refreshData(),
60000 // 1 minute
);
return service;
}
async stop(): Promise<void> {
// Cleanup resources
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
// Close connections
if (this.client) {
await this.client.disconnect();
}
logger.info('MyService stopped');
}
private async initialize(): Promise<void> {
// Service initialization logic
const apiKey = this.runtime.getSetting('MY_API_KEY');
if (!apiKey) {
throw new Error('MY_API_KEY not configured');
}
this.client = new MyClient({ apiKey });
await this.client.connect();
}
}
Service Lifecycle Patterns
Services have a specific lifecycle that plugins must respect:
1. Delayed Initialization
Sometimes services need to wait for other services or perform startup tasks:
export class MyService extends Service {
static serviceType = 'my-service';
static async start(runtime: IAgentRuntime): Promise<MyService> {
const service = new MyService(runtime);
// Immediate initialization
await service.initialize();
// Delayed initialization for non-critical tasks
setTimeout(async () => {
try {
await service.loadCachedData();
await service.syncWithRemote();
logger.info('MyService: Delayed initialization complete');
} catch (error) {
logger.error('MyService: Delayed init failed', error);
// Don't throw - service is still functional
}
}, 5000);
return service;
}
}
2. Actions
Actions are the heart of what your agent can DO. They’re intelligent, context-aware operations that can make decisions, interact with services, and chain together for complex workflows.
Quick Reference
Component | Purpose | Required | Returns |
---|
name | Unique identifier | ✅ | - |
description | Help LLM understand when to use | ✅ | - |
similes | Alternative names for fuzzy matching | ❌ | - |
validate | Check if action can run | ✅ | Promise<boolean> |
handler | Execute the action logic | ✅ | Promise<ActionResult> |
examples | Teach LLM through scenarios | ✅ | - |
Key Concepts:
- Actions MUST return
ActionResult
with success
field
- Actions receive composed state from providers
- Actions can chain together, passing values through state
- Actions can use callbacks for intermediate responses
- Actions are selected by the LLM based on validation and examples
Action Anatomy
import {
type Action,
type ActionExample,
type ActionResult,
type HandlerCallback,
type IAgentRuntime,
type Memory,
type State,
ModelType,
composePromptFromState,
logger,
} from '@elizaos/core';
export const myAction: Action = {
name: 'MY_ACTION',
description: 'Clear, concise description for the LLM to understand when to use this',
// Similes help with fuzzy matching - be creative!
similes: ['SIMILAR_ACTION', 'ANOTHER_NAME', 'CASUAL_REFERENCE'],
// Validation: Can this action run in the current context?
validate: async (runtime: IAgentRuntime, message: Memory, state?: State): Promise<boolean> => {
// Check permissions, settings, current state, etc.
const hasPermission = await checkUserPermissions(runtime, message);
const serviceAvailable = runtime.getService('my-service') !== null;
return hasPermission && serviceAvailable;
},
// Handler: The brain of your action
handler: async (
runtime: IAgentRuntime,
message: Memory,
state?: State,
options?: { [key: string]: unknown },
callback?: HandlerCallback,
responses?: Memory[]
): Promise<ActionResult> => {
// ALWAYS return ActionResult with success field!
try {
// Access previous action results from multi-step chains
const context = options?.context;
const previousResults = context?.previousResults || [];
// Get your state (providers have already run)
if (!state) {
state = await runtime.composeState(message, [
'RECENT_MESSAGES',
'CHARACTER',
'ACTION_STATE', // Includes previous action results
]);
}
// Your action logic here
const result = await doSomethingAmazing();
// Use callback for intermediate responses
if (callback) {
await callback({
text: `Working on it...`,
actions: ['MY_ACTION'],
});
}
// Return structured result
return {
success: true, // REQUIRED field
text: `Action completed: ${result.summary}`,
values: {
// These merge into state for next actions
lastActionTime: Date.now(),
resultData: result.data,
},
data: {
// Raw data for logging/debugging
actionName: 'MY_ACTION',
fullResult: result,
},
};
} catch (error) {
logger.error('Action failed:', error);
return {
success: false, // REQUIRED field
text: 'Failed to complete action',
error: error instanceof Error ? error : new Error(String(error)),
data: {
actionName: 'MY_ACTION',
errorDetails: error.message,
},
};
}
},
// Examples: Teach the LLM through scenarios
examples: [
[
{
name: '{{user1}}',
content: { text: 'Can you do the thing?' },
},
{
name: '{{agent}}',
content: {
text: "I'll do that for you right away!",
actions: ['MY_ACTION'],
},
},
],
] as ActionExample[][],
};
Real-World Action Patterns
1. Decision-Making Actions
Actions can use the LLM to make intelligent decisions based on context:
export const muteRoomAction: Action = {
name: 'MUTE_ROOM',
similes: ['SHUT_UP', 'BE_QUIET', 'STOP_TALKING', 'SILENCE'],
description: 'Mutes a room if asked to or if the agent is being annoying',
validate: async (runtime, message) => {
// Check if already muted
const roomState = await runtime.getParticipantUserState(message.roomId, runtime.agentId);
return roomState !== 'MUTED';
},
handler: async (runtime, message, state) => {
// Create a decision prompt
const shouldMuteTemplate = `# Task: Should {{agentName}} mute this room?
{{recentMessages}}
Should {{agentName}} mute and stop responding unless mentioned?
Respond YES if:
- User asked to stop/be quiet
- Agent responses are annoying users
- Conversation is hostile
Otherwise NO.`;
const prompt = composePromptFromState({ state, template: shouldMuteTemplate });
const decision = await runtime.useModel(ModelType.TEXT_SMALL, {
prompt,
runtime,
});
if (decision.toLowerCase().includes('yes')) {
await runtime.setParticipantUserState(message.roomId, runtime.agentId, 'MUTED');
return {
success: true,
text: 'Going silent in this room',
values: { roomMuted: true },
};
}
return {
success: true,
text: 'Continuing to participate',
values: { roomMuted: false },
};
},
};
2. Multi-Target Actions
Actions can handle complex targeting and routing:
export const sendMessageAction: Action = {
name: 'SEND_MESSAGE',
similes: ['DM', 'MESSAGE', 'PING', 'TELL', 'NOTIFY'],
description: 'Send a message to a user or room on any platform',
handler: async (runtime, message, state, options, callback) => {
// Extract target using LLM
const targetPrompt = composePromptFromState({
state,
template: `Extract message target from: {{recentMessages}}
Return JSON:
{
"targetType": "user|room",
"source": "platform",
"identifiers": { "username": "...", "roomName": "..." }
}`,
});
const targetData = await runtime.useModel(ModelType.OBJECT_LARGE, {
prompt: targetPrompt,
schema: targetSchema,
});
// Route to appropriate service
if (targetData.targetType === 'user') {
const user = await findEntityByName(runtime, message, state);
const service = runtime.getService(targetData.source);
await service.sendDirectMessage(runtime, user.id, message.content.text);
return {
success: true,
text: `Message sent to ${user.names[0]}`,
values: { messageSent: true, targetUser: user.names[0] },
};
}
// Handle room messages similarly...
},
};
3. Stateful Reply Action
The simplest yet most important action - generating contextual responses:
export const replyAction: Action = {
name: 'REPLY',
similes: ['RESPOND', 'ANSWER', 'SPEAK', 'SAY'],
description: 'Generate and send a response to the conversation',
validate: async () => true, // Always valid
handler: async (runtime, message, state, options, callback) => {
// Access chain context
const previousActions = options?.context?.previousResults || [];
// Include dynamic providers from previous actions
const dynamicProviders = responses?.flatMap((r) => r.content?.providers ?? []) ?? [];
// Compose state with action results
state = await runtime.composeState(message, [
...dynamicProviders,
'RECENT_MESSAGES',
'ACTION_STATE', // Includes previous action results
]);
const replyTemplate = `# Generate response as {{agentName}}
{{providers}}
Previous actions taken: {{actionResults}}
Generate thoughtful response considering the context and any actions performed.
\`\`\`json
{
"thought": "reasoning about response",
"message": "the actual response"
}
\`\`\``;
const response = await runtime.useModel(ModelType.OBJECT_LARGE, {
prompt: composePromptFromState({ state, template: replyTemplate }),
});
await callback({
text: response.message,
thought: response.thought,
actions: ['REPLY'],
});
return {
success: true,
text: 'Reply sent',
values: {
lastReply: response.message,
thoughtProcess: response.thought,
},
};
},
};
4. Permission-Based Actions
Actions that modify system state with permission checks:
export const updateRoleAction: Action = {
name: 'UPDATE_ROLE',
similes: ['MAKE_ADMIN', 'CHANGE_PERMISSIONS', 'PROMOTE', 'DEMOTE'],
description: 'Update user roles with permission validation',
validate: async (runtime, message) => {
// Only in group contexts with server ID
return message.content.channelType === ChannelType.GROUP && !!message.content.serverId;
},
handler: async (runtime, message, state) => {
// Get requester's role
const world = await runtime.getWorld(worldId);
const requesterRole = world.metadata?.roles[message.entityId] || Role.NONE;
// Extract role changes using LLM
const changes = await runtime.useModel(ModelType.OBJECT_LARGE, {
prompt: 'Extract role assignments from: ' + state.text,
schema: {
type: 'array',
items: {
type: 'object',
properties: {
entityId: { type: 'string' },
newRole: {
type: 'string',
enum: ['OWNER', 'ADMIN', 'NONE'],
},
},
},
},
});
// Validate each change
const results = [];
for (const change of changes) {
if (canModifyRole(requesterRole, currentRole, change.newRole)) {
world.metadata.roles[change.entityId] = change.newRole;
results.push({ success: true, ...change });
} else {
results.push({
success: false,
reason: 'Insufficient permissions',
...change,
});
}
}
await runtime.updateWorld(world);
return {
success: results.some((r) => r.success),
text: `Updated ${results.filter((r) => r.success).length} roles`,
data: { results },
};
},
};
Action Best Practices
-
Always Return ActionResult
// ❌ Old style - DON'T DO THIS
return true;
// ✅ New style - ALWAYS DO THIS
return {
success: true,
text: 'Action completed',
values: {
/* state updates */
},
data: {
/* raw data */
},
};
-
Use Callbacks for User Feedback
// Acknowledge immediately
await callback?.({
text: "I'm working on that...",
actions: ['MY_ACTION'],
});
// Do the work
const result = await longRunningOperation();
// Final response
await callback?.({
text: `Done! ${result.summary}`,
actions: ['MY_ACTION_COMPLETE'],
});
-
Chain Actions with Context
// Access previous results
const previousResults = options?.context?.previousResults || [];
const lastResult = previousResults[previousResults.length - 1];
if (lastResult?.data?.needsFollowUp) {
// Continue the chain
}
-
Validate Thoughtfully
validate: async (runtime, message, state) => {
// Check multiple conditions
const hasPermission = await checkPermissions(runtime, message);
const hasRequiredService = !!runtime.getService('required-service');
const isRightContext = message.content.channelType === ChannelType.GROUP;
return hasPermission && hasRequiredService && isRightContext;
};
-
Write Teaching Examples
examples: [
// Show the happy path
[
{ name: '{{user}}', content: { text: 'Please do X' } },
{
name: '{{agent}}',
content: {
text: 'Doing X now!',
actions: ['DO_X'],
},
},
],
// Show edge cases
[
{ name: '{{user}}', content: { text: 'Do X without permission' } },
{
name: '{{agent}}',
content: {
text: "I don't have permission for that",
actions: ['REPLY'],
},
},
],
// Show the action being ignored when not relevant
[
{ name: '{{user}}', content: { text: 'Unrelated conversation' } },
{
name: '{{agent}}',
content: {
text: 'Responding normally',
actions: ['REPLY'],
},
},
],
];
Understanding ActionResult
The ActionResult
interface is crucial for action interoperability:
interface ActionResult {
// REQUIRED: Indicates if the action succeeded
success: boolean;
// Optional: User-facing message about what happened
text?: string;
// Optional: Values to merge into state for subsequent actions
values?: Record<string, any>;
// Optional: Raw data for logging/debugging
data?: Record<string, any>;
// Optional: Error information if action failed
error?: string | Error;
}
Why ActionResult Matters:
- State Propagation: Values from one action flow to the next
- Error Handling: Consistent error reporting across all actions
- Logging: Structured data for debugging and analytics
- Action Chaining: Success/failure determines flow control
Action Execution Lifecycle
// 1. Provider phase - gather context
const state = await runtime.composeState(message, ['ACTIONS']);
// 2. Action selection - LLM chooses based on available actions
const validActions = await getValidActions(runtime, message, state);
// 3. Action execution - may include multiple actions
await runtime.processActions(message, responses, state, callback);
// 4. State accumulation - each action's values merge into state
// Action 1 returns: { values: { step1Complete: true } }
// Action 2 receives: state.values.step1Complete === true
// 5. Evaluator phase - post-processing
await runtime.evaluate(message, state, true, callback, responses);
Advanced Action Techniques
Dynamic Action Registration
// Actions can register other actions dynamically
export const pluginLoaderAction: Action = {
name: 'LOAD_PLUGIN',
handler: async (runtime, message, state) => {
const pluginName = extractPluginName(state);
const plugin = await import(pluginName);
// Register new actions from the loaded plugin
if (plugin.actions) {
for (const action of plugin.actions) {
runtime.registerAction(action);
}
}
return {
success: true,
text: `Loaded ${plugin.actions.length} new actions`,
values: {
loadedPlugin: pluginName,
newActions: plugin.actions.map((a) => a.name),
},
};
},
};
Conditional Action Chains
export const conditionalWorkflow: Action = {
name: 'SMART_WORKFLOW',
handler: async (runtime, message, state, options, callback) => {
// Step 1: Analyze
const analysis = await analyzeRequest(state);
if (analysis.requiresApproval) {
// Trigger approval action
await callback({
text: 'This requires approval',
actions: ['REQUEST_APPROVAL'],
});
return {
success: true,
values: {
workflowPaused: true,
pendingApproval: true,
},
};
}
// Step 2: Execute
if (analysis.complexity === 'simple') {
return await executeSimpleTask(runtime, analysis);
} else {
// Trigger complex workflow
await callback({
text: 'Starting complex workflow',
actions: ['COMPLEX_WORKFLOW'],
});
return {
success: true,
values: {
workflowType: 'complex',
analysisData: analysis,
},
};
}
},
};
Action Composition
// Compose multiple actions into higher-level operations
export const compositeAction: Action = {
name: 'SEND_AND_TRACK',
description: 'Send a message and track its delivery',
handler: async (runtime, message, state, options, callback) => {
// Execute sub-actions
const sendResult = await sendMessageAction.handler(runtime, message, state, options, callback);
if (!sendResult.success) {
return sendResult; // Propagate failure
}
// Track the sent message
const trackingId = generateTrackingId();
await runtime.createMemory(
{
id: trackingId,
entityId: message.entityId,
roomId: message.roomId,
content: {
type: 'message_tracking',
sentTo: sendResult.data.targetId,
sentAt: Date.now(),
messageContent: sendResult.data.messageContent,
},
},
'tracking'
);
return {
success: true,
text: `Message sent and tracked (${trackingId})`,
values: {
...sendResult.values,
trackingId,
tracked: true,
},
data: {
sendResult,
trackingId,
},
};
},
};
Self-Modifying Actions
export const learningAction: Action = {
name: 'ADAPTIVE_RESPONSE',
handler: async (runtime, message, state) => {
// Retrieve past performance
const history = await runtime.getMemories({
tableName: 'action_feedback',
roomId: message.roomId,
count: 100,
});
// Analyze what worked well
const analysis = await runtime.useModel(ModelType.TEXT_LARGE, {
prompt: `Analyze these past interactions and identify patterns:
${JSON.stringify(history)}
What response strategies were most effective?`,
});
// Adapt behavior based on learning
const strategy = determineStrategy(analysis);
const response = await generateResponse(state, strategy);
// Store for future learning
await runtime.createMemory(
{
id: generateId(),
content: {
type: 'action_feedback',
strategy: strategy.name,
context: state.text,
response: response.text,
},
},
'action_feedback'
);
return {
success: true,
text: response.text,
values: {
strategyUsed: strategy.name,
confidence: strategy.confidence,
},
};
},
};
Testing Actions
Actions should be thoroughly tested to ensure they behave correctly in various scenarios:
// __tests__/myAction.test.ts
import { describe, it, expect, beforeEach } from 'bun:test';
import { myAction } from '../src/actions/myAction';
import { createMockRuntime } from '@elizaos/test-utils';
import { ActionResult, Memory, State } from '@elizaos/core';
describe('MyAction', () => {
let mockRuntime: any;
let mockMessage: Memory;
let mockState: State;
beforeEach(() => {
mockRuntime = createMockRuntime({
settings: { MY_API_KEY: 'test-key' },
});
mockMessage = {
id: 'test-id',
entityId: 'user-123',
roomId: 'room-456',
content: { text: 'Do the thing' },
};
mockState = {
values: { recentMessages: 'test context' },
data: { room: { name: 'Test Room' } },
text: 'State text',
};
});
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 = () => 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 = jest.fn();
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 = () => {
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');
}
}
});
});
});
E2E Testing Actions
For integration testing with a live runtime:
// tests/e2e/myAction.e2e.ts
export const myActionE2ETests = {
name: 'MyAction E2E Tests',
tests: [
{
name: 'should execute full action 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);
},
},
],
};
3. Providers
Providers supply contextual information to the agent’s state before it makes decisions. They act as the agent’s “senses”, gathering relevant data that helps the LLM understand the current context.
import { Provider, ProviderResult, IAgentRuntime, Memory, State, addHeader } from '@elizaos/core';
export const myProvider: Provider = {
name: 'myProvider',
description: 'Provides contextual information about X',
// Optional: Set to true if this provider should only run when explicitly requested
dynamic: false,
// Optional: Control execution order (lower numbers run first, can be negative)
position: 100,
// Optional: Set to true to exclude from default provider list
private: false,
get: async (runtime: IAgentRuntime, message: Memory, state: State): Promise<ProviderResult> => {
try {
const service = runtime.getService('my-service') as MyService;
const data = await service.getCurrentData();
// Format data for LLM context
const formattedText = addHeader(
'# Current System Status',
`Field 1: ${data.field1}
Field 2: ${data.field2}
Last updated: ${new Date(data.timestamp).toLocaleString()}`
);
return {
// Text that will be included in the LLM prompt
text: formattedText,
// Values that can be accessed by other providers/actions
values: {
currentField1: data.field1,
currentField2: data.field2,
lastUpdate: data.timestamp,
},
// Raw data for internal use
data: {
raw: data,
processed: true,
},
};
} catch (error) {
return {
text: 'Unable to retrieve current status',
values: {},
data: { error: error.message },
};
}
},
};
Provider Properties
name
(required): Unique identifier for the provider
description
(optional): Human-readable description of what the provider does
dynamic
(optional, default: false): If true, the provider is not included in default state composition and must be explicitly requested
position
(optional, default: 0): Controls execution order. Lower numbers execute first. Can be negative for early execution
private
(optional, default: false): If true, the provider is excluded from regular provider lists and must be explicitly included
Provider Execution Flow
- Providers are executed during
runtime.composeState()
- By default, all non-private, non-dynamic providers are included
- Providers are sorted by position and executed in order
- Results are aggregated into a unified state object
- The composed state is passed to actions and the LLM for decision-making
Common Provider Patterns
Recent Messages Provider (position: 100)
export const recentMessagesProvider: Provider = {
name: 'RECENT_MESSAGES',
description: 'Recent messages, interactions and other memories',
position: 100, // Runs after most other providers
get: async (runtime, message) => {
const messages = await runtime.getMemories({
roomId: message.roomId,
count: runtime.getConversationLength(),
unique: false,
});
const formattedMessages = formatMessages(messages);
return {
text: addHeader('# Conversation Messages', formattedMessages),
values: { recentMessages: formattedMessages },
data: { messages },
};
},
};
Actions Provider (position: -1)
export const actionsProvider: Provider = {
name: 'ACTIONS',
description: 'Possible response actions',
position: -1, // Runs early to inform other providers
get: async (runtime, message, state) => {
// Get all valid actions for this context
const validActions = await Promise.all(
runtime.actions.map(async (action) => {
const isValid = await action.validate(runtime, message, state);
return isValid ? action : null;
})
);
const actions = validActions.filter(Boolean);
const actionNames = formatActionNames(actions);
return {
text: `Possible response actions: ${actionNames}`,
values: { actionNames },
data: { actionsData: actions },
};
},
};
Dynamic Knowledge Provider
export const knowledgeProvider: Provider = {
name: 'KNOWLEDGE',
description: 'Knowledge from the knowledge base',
dynamic: true, // Only runs when explicitly requested
get: async (runtime, message) => {
const knowledgeService = runtime.getService('knowledge');
const relevantKnowledge = await knowledgeService.search(message.content.text);
if (!relevantKnowledge.length) {
return { text: '', values: {}, data: {} };
}
return {
text: addHeader('# Relevant Knowledge', formatKnowledge(relevantKnowledge)),
values: { knowledgeUsed: true },
data: { knowledge: relevantKnowledge },
};
},
};
4. Evaluators
Evaluators run after the agent generates a response, allowing for analysis, learning, and side effects. They use the same handler pattern as actions but run post-response.
import { Evaluator, IAgentRuntime, Memory, State, HandlerCallback } from '@elizaos/core';
export const myEvaluator: Evaluator = {
name: 'myEvaluator',
description: 'Analyzes responses for quality and extracts insights',
// Examples help the LLM understand when to use this evaluator
examples: [
{
prompt: 'User asks about product pricing',
messages: [
{ name: 'user', content: { text: 'How much does it cost?' } },
{ name: 'assistant', content: { text: 'The price is $99' } },
],
outcome: 'Extract pricing information for future reference',
},
],
// Similar descriptions for fuzzy matching
similes: ['RESPONSE_ANALYZER', 'QUALITY_CHECK'],
// Optional: Run even if the agent didn't respond
alwaysRun: false,
// Validation: Determines if evaluator should run
validate: async (runtime: IAgentRuntime, message: Memory, state?: State): Promise<boolean> => {
// Example: Only run for certain types of responses
return message.content?.text?.includes('transaction') || false;
},
// Handler: Main evaluation logic
handler: async (
runtime: IAgentRuntime,
message: Memory,
state?: State,
options?: { [key: string]: unknown },
callback?: HandlerCallback,
responses?: Memory[]
): Promise<void> => {
try {
// Analyze the response
const responseText = responses?.[0]?.content?.text || '';
if (responseText.includes('transaction')) {
// Extract and store transaction data
const txHash = extractTransactionHash(responseText);
if (txHash) {
// Store for future reference
await runtime.createMemory(
{
id: generateId(),
entityId: message.entityId,
roomId: message.roomId,
content: {
text: `Transaction processed: ${txHash}`,
type: 'transaction_record',
data: { txHash, timestamp: Date.now() },
},
},
'facts'
);
// Log the evaluation
await runtime.adapter.log({
entityId: message.entityId,
roomId: message.roomId,
type: 'evaluator',
body: {
evaluator: 'myEvaluator',
result: 'transaction_extracted',
txHash,
},
});
}
}
// Can also trigger follow-up actions via callback
if (callback) {
callback({
text: 'Analysis complete',
content: { analyzed: true },
});
}
} catch (error) {
runtime.logger.error('Evaluator error:', error);
}
},
};
Common Evaluator Patterns
Fact Extraction Evaluator
export const factExtractor: Evaluator = {
name: 'FACT_EXTRACTOR',
description: 'Extracts facts from conversations for long-term memory',
alwaysRun: true, // Run after every response
validate: async () => true, // Always valid
handler: async (runtime, message, state, options, callback, responses) => {
const facts = await extractFactsFromConversation(runtime, message, responses);
for (const fact of facts) {
await runtime.createMemory(
{
id: generateId(),
entityId: message.entityId,
roomId: message.roomId,
content: {
text: fact.statement,
type: 'fact',
confidence: fact.confidence,
},
},
'facts',
true
); // unique = true to avoid duplicates
}
},
};
Response Quality Evaluator
export const qualityEvaluator: Evaluator = {
name: 'QUALITY_CHECK',
description: 'Evaluates response quality and coherence',
validate: async (runtime, message) => {
// Only evaluate responses to direct questions
return message.content?.text?.includes('?') || false;
},
handler: async (runtime, message, state, options, callback, responses) => {
const quality = await assessResponseQuality(responses[0]);
if (quality.score < 0.7) {
// Log low quality response for review
await runtime.adapter.log({
entityId: message.entityId,
roomId: message.roomId,
type: 'quality_alert',
body: {
score: quality.score,
issues: quality.issues,
responseId: responses[0].id,
},
});
}
},
};
Understanding the Agent Lifecycle: Providers, Actions, and Evaluators
When an agent receives a message, components execute in this order:
-
Providers gather context by calling runtime.composeState()
- Non-private, non-dynamic providers run automatically
- Sorted by position (lower numbers first)
- Results aggregated into state object
-
Actions are validated and presented to the LLM
- The actions provider lists available actions
- LLM decides which actions to execute
- Actions execute with the composed state
-
Evaluators run after response generation
- Process the response for insights
- Can store memories, log events, or trigger follow-ups
- Use
alwaysRun: true
to run even without a response
// Example flow in pseudocode
async function processMessage(message: Memory) {
// 1. Compose state with providers
const state = await runtime.composeState(message, ['RECENT_MESSAGES', 'CHARACTER', 'ACTIONS']);
// 2. Generate response using LLM with composed state
const response = await generateResponse(state);
// 3. Execute any actions the LLM chose
if (response.actions?.length > 0) {
await runtime.processActions(message, [response], state);
}
// 4. Run evaluators on the response
await runtime.evaluate(message, state, true, callback, [response]);
}
Accessing Provider Data in Actions
Actions receive the composed state containing all provider data. Here’s how to access it:
export const myAction: Action = {
name: 'MY_ACTION',
handler: async (runtime, message, state, options, callback) => {
// Access provider values
const recentMessages = state.values?.recentMessages;
const actionNames = state.values?.actionNames;
// Access raw provider data
const providerData = state.data?.providers;
if (providerData) {
// Get specific provider's data
const knowledgeData = providerData['KNOWLEDGE']?.data;
const characterData = providerData['CHARACTER']?.data;
}
// Access action execution context
const previousResults = state.data?.actionResults || [];
const actionPlan = state.data?.actionPlan;
// Use the data to make decisions
if (state.values?.knowledgeUsed) {
// Knowledge was found, incorporate it
}
},
};
Custom Provider Inclusion
To include specific providers when composing state:
// Include only specific providers
const state = await runtime.composeState(message, ['CHARACTER', 'KNOWLEDGE'], true);
// Add extra providers to the default set
const state = await runtime.composeState(message, ['KNOWLEDGE', 'CUSTOM_PROVIDER']);
// Skip cache and regenerate
const state = await runtime.composeState(message, null, false, true);
5. Model Handlers (LLM Plugins)
For LLM plugins, implement model handlers for different model types:
import { ModelType, GenerateTextParams, EventType } from '@elizaos/core';
export const models = {
[ModelType.TEXT_SMALL]: async (
runtime: IAgentRuntime,
params: GenerateTextParams
): Promise<string> => {
const client = createClient(runtime);
const { text, usage } = await client.generateText({
model: getSmallModel(runtime),
prompt: params.prompt,
temperature: params.temperature ?? 0.7,
maxTokens: params.maxTokens ?? 4096,
});
// Emit usage event
runtime.emitEvent(EventType.MODEL_USED, {
provider: 'my-llm',
type: ModelType.TEXT_SMALL,
tokens: usage,
});
return text;
},
[ModelType.TEXT_EMBEDDING]: async (
runtime: IAgentRuntime,
params: TextEmbeddingParams | string | null
): Promise<number[]> => {
if (params === null) {
// Return test embedding
return Array(1536).fill(0);
}
const text = typeof params === 'string' ? params : params.text;
const embedding = await client.createEmbedding(text);
return embedding;
},
};
6. HTTP Routes and API Endpoints
Plugins can expose HTTP endpoints for webhooks, APIs, or web interfaces. Routes are defined using the Route
type and exported as part of the plugin:
import { Plugin, Route, IAgentRuntime } from '@elizaos/core';
// Define route handlers
async function statusHandler(req: any, res: any, runtime: IAgentRuntime) {
try {
const service = runtime.getService('my-service') as MyService;
const status = await service.getStatus();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
success: true,
data: status,
timestamp: new Date().toISOString(),
})
);
} catch (error) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
success: false,
error: error.message,
})
);
}
}
// Export routes array
export const myPluginRoutes: Route[] = [
{
type: 'GET',
path: '/api/status',
handler: statusHandler,
public: true, // Makes this route discoverable in UI
name: 'API Status', // Display name for UI tab
},
{
type: 'POST',
path: '/api/webhook',
handler: webhookHandler,
},
];
// Include routes in plugin definition
export const myPlugin: Plugin = {
name: 'my-plugin',
description: 'My plugin with HTTP routes',
services: [MyService],
routes: myPluginRoutes, // Add routes here
};
Response Helpers
Create consistent API responses:
// Helper functions for standardized responses
function sendSuccess(res: any, data: any, status = 200) {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, data }));
}
function sendError(res: any, status: number, message: string, details?: any) {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
success: false,
error: { message, details },
})
);
}
// Use in handlers
async function apiHandler(req: any, res: any, runtime: IAgentRuntime) {
try {
const result = await processRequest(req.body);
sendSuccess(res, result);
} catch (error) {
sendError(res, 500, 'Processing failed', error.message);
}
}
Authentication Patterns
Implement authentication in route handlers:
async function authenticatedHandler(req: any, res: any, runtime: IAgentRuntime) {
// Check API key from headers
const apiKey = req.headers['x-api-key'];
const validKey = runtime.getSetting('API_KEY');
if (!apiKey || apiKey !== validKey) {
sendError(res, 401, 'Unauthorized');
return;
}
// Process authenticated request
const data = await processAuthenticatedRequest(req);
sendSuccess(res, data);
}
File Upload Support
Handle multipart form data:
// The server automatically applies multer middleware for multipart routes
export const uploadRoute: Route = {
type: 'POST',
path: '/api/upload',
handler: async (req: any, res: any, runtime: IAgentRuntime) => {
// Access uploaded files via req.files
const files = req.files as Express.Multer.File[];
if (!files || files.length === 0) {
sendError(res, 400, 'No files uploaded');
return;
}
// Process uploaded files
for (const file of files) {
await processFile(file.buffer, file.originalname);
}
sendSuccess(res, { processed: files.length });
},
isMultipart: true, // Enable multipart handling
};
Advanced: Creating Plugins Manually
For developers working within the ElizaOS monorepo or those who need complete control over their plugin structure, you can create plugins manually. This approach is useful when:
- Contributing directly to the ElizaOS monorepo
- Creating highly customized plugin structures
- Integrating with existing codebases
- Learning the internals of plugin architecture
Step 1: Set Up the Project
# Create plugin directory
mkdir packages/plugin-myplugin
cd packages/plugin-myplugin
# Initialize package.json
bun init -y
# Install dependencies
bun add @elizaos/core zod
bun add -d typescript tsup @types/node @types/bun
# Create directory structure
mkdir -p src/{actions,providers,types,constants}
{
"name": "@yourorg/plugin-myplugin",
"version": "1.0.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
}
},
"files": ["dist"],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"test": "bun test",
"test:watch": "bun test --watch",
"test:coverage": "bun test --coverage",
"lint": "eslint ./src --ext .ts",
"format": "prettier --write ./src"
},
"dependencies": {
"@elizaos/core": "^1.0.0",
"zod": "^3.24.2"
},
"devDependencies": {
"typescript": "^5.8.3",
"tsup": "^8.4.0",
"@types/bun": "^1.2.16",
"@types/node": "^22.15.3"
},
"agentConfig": {
"pluginType": "elizaos:plugin:1.0.0",
"pluginParameters": {
"MY_API_KEY": {
"type": "string",
"description": "API key for MyPlugin",
"required": true,
"sensitive": true
}
}
}
}
Step 3: Create TypeScript Configuration
// tsconfig.json
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
// 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'],
});
# bunfig.toml
[install]
# Optional: Configure registry
# registry = "https://registry.npmjs.org"
# Optional: Save exact versions
save-exact = true
# Optional: Configure trusted dependencies for postinstall scripts
# trustedDependencies = ["package-name"]
Step 4: Implement the Plugin
// src/index.ts
import type { Plugin } from '@elizaos/core';
import { MyService } from './service';
import { myAction } from './actions/myAction';
import { myProvider } from './providers/myProvider';
export const myPlugin: Plugin = {
name: 'myplugin',
description: 'My custom plugin for ElizaOS',
services: [MyService], // Pass the class constructor, not an instance
actions: [myAction],
providers: [myProvider],
routes: myPluginRoutes, // Optional: HTTP endpoints
init: async (config: Record<string, string>, runtime: IAgentRuntime) => {
// Optional initialization logic
logger.info('MyPlugin initialized');
},
};
export default myPlugin;
Plugin Types and Patterns
Plugin Dependencies and Priority
Plugins can declare dependencies on other plugins and control their loading order:
export const myPlugin: Plugin = {
name: 'my-plugin',
description: 'Plugin that depends on other plugins',
// Required dependencies - plugin won't load without these
dependencies: ['plugin-sql', 'plugin-bootstrap'],
// Optional test dependencies
testDependencies: ['plugin-test-utils'],
// Higher priority = loads earlier (default: 0)
priority: 100,
async init(config, runtime) {
// Dependencies are guaranteed to be loaded
const sqlService = runtime.getService('sql');
if (!sqlService) {
throw new Error('SQL service not found despite dependency');
}
},
};
Checking for Optional Dependencies
async init(config, runtime) {
// Check if optional plugin is available
const hasKnowledgePlugin = runtime.getService('knowledge') !== null;
if (hasKnowledgePlugin) {
logger.info('Knowledge plugin detected, enabling enhanced features');
this.enableKnowledgeIntegration = true;
}
}
Platform plugins connect agents to communication platforms:
// Key patterns for platform plugins:
// 1. Entity mapping (server → world, channel → room, user → entity)
// 2. Message conversion
// 3. Event handling
// 4. Rate limiting
export class PlatformService extends Service {
private client: PlatformClient;
private messageManager: MessageManager;
async handleIncomingMessage(platformMessage: any) {
// 1. Sync entities
const { worldId, roomId, userId } = await this.syncEntities(platformMessage);
// 2. Convert to Memory
const memory = await this.messageManager.convertToMemory(platformMessage, roomId, userId);
// 3. Process through runtime
await this.runtime.processMemory(memory);
// 4. Emit events
await this.runtime.emit(EventType.MESSAGE_RECEIVED, memory);
}
}
LLM Plugins
LLM plugins integrate different AI model providers:
// Key patterns for LLM plugins:
// 1. Model type handlers
// 2. Configuration management
// 3. Usage tracking
// 4. Error handling
export const llmPlugin: Plugin = {
name: 'my-llm',
models: {
[ModelType.TEXT_LARGE]: async (runtime, params) => {
const client = createClient(runtime);
const response = await client.generate(params);
// Track usage
runtime.emitEvent(EventType.MODEL_USED, {
provider: 'my-llm',
tokens: response.usage,
});
return response.text;
},
},
};
Event Handling
Plugins can register event handlers to react to system events:
import { EventType, EventHandler } from '@elizaos/core';
export const myPlugin: Plugin = {
name: 'my-plugin',
events: {
// Handle message events
[EventType.MESSAGE_CREATED]: [
async (params) => {
logger.info('New message created:', params.message.id);
// React to new messages
},
],
// Handle custom events
'custom:data-sync': [
async (params) => {
await syncDataWithExternal(params);
},
],
},
async init(config, runtime) {
// Emit custom events
runtime.emitEvent('custom:data-sync', {
timestamp: Date.now(),
source: 'my-plugin',
});
},
};
DeFi Plugins
DeFi plugins enable blockchain interactions:
// Key patterns for DeFi plugins:
// 1. Wallet management
// 2. Transaction handling
// 3. Gas optimization
// 4. Security validation
export class DeFiService extends Service {
private walletClient: WalletClient;
private publicClient: PublicClient;
async executeTransaction(params: TransactionParams) {
// 1. Validate inputs
validateAddress(params.to);
validateAmount(params.amount);
// 2. Estimate gas
const gasLimit = await this.estimateGas(params);
// 3. Execute with retry
return await withRetry(() =>
this.walletClient.sendTransaction({
...params,
gasLimit,
})
);
}
}
Advanced Configuration
Configuration Hierarchy
ElizaOS follows a specific hierarchy for configuration resolution. Understanding this is critical for proper plugin behavior:
// Configuration resolution order (first found wins):
// 1. Runtime settings (via runtime.getSetting())
// 2. Environment variables (process.env)
// 3. Plugin config defaults
// 4. Hardcoded defaults
// IMPORTANT: During init(), runtime might not be fully available!
export async function getConfigValue(
key: string,
runtime?: IAgentRuntime,
defaultValue?: string
): Promise<string | undefined> {
// Try runtime first (if available)
if (runtime) {
const runtimeValue = runtime.getSetting(key);
if (runtimeValue !== undefined) return runtimeValue;
}
// Fall back to environment
const envValue = process.env[key];
if (envValue !== undefined) return envValue;
// Use default
return defaultValue;
}
Init-time vs Runtime Configuration
During plugin initialization, configuration access is different:
export const myPlugin: Plugin = {
name: 'my-plugin',
// Plugin-level config defaults
config: {
DEFAULT_TIMEOUT: 30000,
RETRY_ATTEMPTS: 3,
},
async init(config: Record<string, string>, runtime?: IAgentRuntime) {
// During init, runtime might not be available or fully initialized
// Always check multiple sources:
const apiKey =
config.API_KEY || // From agent character config
runtime?.getSetting('API_KEY') || // From runtime (may be undefined)
process.env.API_KEY; // From environment
if (!apiKey) {
throw new Error('API_KEY required for my-plugin');
}
// For boolean values, be careful with string parsing
const isEnabled =
config.FEATURE_ENABLED === 'true' ||
runtime?.getSetting('FEATURE_ENABLED') === 'true' ||
process.env.FEATURE_ENABLED === 'true';
},
};
Configuration Validation
Use Zod for runtime validation:
import { z } from 'zod';
export const configSchema = z.object({
API_KEY: z.string().min(1, 'API key is required'),
ENDPOINT_URL: z.string().url().optional(),
TIMEOUT: z.number().positive().default(30000),
});
export async function validateConfig(runtime: IAgentRuntime) {
const config = {
API_KEY: runtime.getSetting('MY_API_KEY'),
ENDPOINT_URL: runtime.getSetting('MY_ENDPOINT_URL'),
TIMEOUT: Number(runtime.getSetting('MY_TIMEOUT') || 30000),
};
return configSchema.parse(config);
}
agentConfig in package.json
Declare plugin parameters:
{
"agentConfig": {
"pluginType": "elizaos:plugin:1.0.0",
"pluginParameters": {
"MY_API_KEY": {
"type": "string",
"description": "API key for authentication",
"required": true,
"sensitive": true
},
"MY_TIMEOUT": {
"type": "number",
"description": "Request timeout in milliseconds",
"required": false,
"default": 30000
}
}
}
}
Testing Strategies
Bun provides a built-in test runner that’s fast and compatible with Jest-like syntax. No need for additional testing frameworks.
Unit Tests
Test individual components:
// __tests__/myAction.test.ts
import { describe, it, expect, mock, beforeEach } from 'bun:test';
import { myAction } from '../src/actions/myAction';
import { createMockRuntime } from '@elizaos/test-utils';
describe('MyAction', () => {
beforeEach(() => {
// Reset all mocks before each test
mock.restore();
});
it('should validate when configured', async () => {
const mockRuntime = createMockRuntime({
settings: {
MY_API_KEY: 'test-key',
},
});
const isValid = await myAction.validate(mockRuntime);
expect(isValid).toBe(true);
});
it('should handle action execution', async () => {
const mockRuntime = createMockRuntime();
const mockService = {
executeAction: mock().mockResolvedValue({ success: true }),
};
mockRuntime.getService = mock().mockReturnValue(mockService);
const callback = mock();
const result = await myAction.handler(mockRuntime, mockMessage, mockState, {}, callback);
expect(result).toBe(true);
expect(callback).toHaveBeenCalledWith({
text: expect.stringContaining('Successfully'),
content: expect.objectContaining({ success: true }),
});
});
});
Integration Tests
Test component interactions:
describe('Plugin Integration', () => {
let runtime: IAgentRuntime;
let service: MyService;
beforeAll(async () => {
runtime = await createTestRuntime({
settings: {
MY_API_KEY: process.env.TEST_API_KEY,
},
});
service = await MyService.start(runtime);
});
it('should handle complete flow', async () => {
const message = createTestMessage('Execute my action');
const response = await runtime.processMessage(message);
expect(response.success).toBe(true);
});
});
Test Suite Implementation
Include tests in your plugin:
export class MyPluginTestSuite implements TestSuite {
name = 'myplugin';
tests: Array<{ name: string; fn: (runtime: IAgentRuntime) => Promise<void> }>;
constructor() {
this.tests = [
{
name: 'Test initialization',
fn: this.testInitialization.bind(this),
},
{
name: 'Test action execution',
fn: this.testActionExecution.bind(this),
},
];
}
private async testInitialization(runtime: IAgentRuntime): Promise<void> {
const service = runtime.getService('my-service') as MyService;
if (!service) {
throw new Error('Service not initialized');
}
const isConnected = await service.isConnected();
if (!isConnected) {
throw new Error('Failed to connect');
}
}
}
Security Best Practices
1. Credential Management
// Never hardcode credentials
// ❌ BAD
const apiKey = 'sk-1234...';
// ✅ GOOD
const apiKey = runtime.getSetting('API_KEY');
if (!apiKey) {
throw new Error('API_KEY not configured');
}
function validateInput(input: any): ValidatedInput {
// Validate types
if (typeof input.address !== 'string') {
throw new Error('Invalid address type');
}
// Validate format
if (!isValidAddress(input.address)) {
throw new Error('Invalid address format');
}
// Sanitize input
const sanitized = {
address: input.address.toLowerCase().trim(),
amount: Math.abs(parseFloat(input.amount)),
};
return sanitized;
}
3. Rate Limiting
class RateLimiter {
private requests = new Map<string, number[]>();
canExecute(userId: string, limit = 10, window = 60000): boolean {
const now = Date.now();
const userRequests = this.requests.get(userId) || [];
const recentRequests = userRequests.filter((time) => now - time < window);
if (recentRequests.length >= limit) {
return false;
}
recentRequests.push(now);
this.requests.set(userId, recentRequests);
return true;
}
}
4. Error Handling
export class PluginError extends Error {
constructor(message: string, public code: string, public details?: any) {
super(message);
this.name = 'PluginError';
}
}
async function handleOperation<T>(operation: () => Promise<T>, context: string): Promise<T> {
try {
return await operation();
} catch (error) {
logger.error(`Error in ${context}:`, error);
if (error.code === 'NETWORK_ERROR') {
throw new PluginError('Network connection failed', 'NETWORK_ERROR', { context });
}
throw new PluginError('An unexpected error occurred', 'UNKNOWN_ERROR', {
context,
originalError: error,
});
}
}
Publishing and Distribution
For detailed information on publishing your plugin to npm and the ElizaOS registry, see our Plugin Publishing Guide.
Quick Reference
# Test your plugin
elizaos test
# Dry run to verify everything
elizaos publish --test
# Publish to npm and registry
elizaos publish --npm
The Plugin Publishing Guide covers the complete process including:
- Pre-publication validation
- npm authentication
- GitHub repository setup
- Registry PR submission
- Post-publication updates
Reference Examples
Minimal Plugin
// Minimal viable plugin
import { Plugin } from '@elizaos/core';
export const minimalPlugin: Plugin = {
name: 'minimal',
description: 'A minimal plugin example',
actions: [
{
name: 'HELLO',
description: 'Says hello',
validate: async () => true,
handler: async (runtime, message, state, options, callback) => {
callback?.({ text: 'Hello from minimal plugin!' });
return true;
},
examples: [],
},
],
};
export default minimalPlugin;
Complete Templates
- Quick Start Templates: Use
elizaos create
for instant plugin scaffolding with pre-configured TypeScript, build tools, and example components
Common Patterns and Utilities
Retry Logic
async function withRetry<T>(fn: () => Promise<T>, maxRetries = 3, delay = 1000): Promise<T> {
let lastError: Error;
for (let i = 0; i < maxRetries; i++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (i < maxRetries - 1) {
await new Promise((resolve) => setTimeout(resolve, delay * (i + 1)));
}
}
}
throw lastError!;
}
Troubleshooting
Common Issues
- Plugin not loading: Check that the plugin is properly exported and listed in the agent’s plugins array
- Configuration errors: Verify all required settings are provided
- Type errors: Ensure @elizaos/core version matches other plugins
- Runtime errors: Check service initialization and error handling
Debug Tips
// Enable detailed logging
import { elizaLogger } from '@elizaos/core';
elizaLogger.level = 'debug';
// Add debug logging
elizaLogger.debug('Plugin state:', {
service: !!runtime.getService('my-service'),
settings: runtime.getSetting('MY_API_KEY') ? 'set' : 'missing',
});
Bun-Specific Tips
When developing plugins with Bun:
- Fast Installation: Bun’s package installation is significantly faster than npm
- Built-in TypeScript: No need for separate TypeScript compilation during development
- Native Test Runner: Use
bun test
for running tests without additional setup (no vitest needed)
- Workspace Support: Bun handles monorepo workspaces efficiently
- Lock File: Bun uses
bun.lockb
(binary format) for faster dependency resolution
Bun Test Features
Bun’s built-in test runner provides:
- Jest-compatible API (
describe
, it
, expect
, mock
)
- Built-in mocking with
mock()
from ‘bun:test’
- Fast execution with no compilation step
- Coverage reports with
--coverage
flag
- Watch mode with
--watch
flag
- Snapshot testing support
Test File Conventions
Bun automatically discovers test files matching these patterns:
*.test.ts
or *.test.js
*.spec.ts
or *.spec.js
- Files in
__tests__/
directories
- Files in
test/
or tests/
directories
Useful Bun Commands for Plugin Development
# Install all dependencies
bun install
# Add a new dependency
bun add <package-name>
# Add a dev dependency
bun add -d <package-name>
# Run scripts
bun run <script-name>
# Run tests
bun test
# Update dependencies
bun update
# Clean install (remove node_modules and reinstall)
bun install --force
Best Practices Summary
- Architecture: Follow the standard plugin structure
- Type Safety: Use TypeScript strictly
- Error Handling: Always handle errors gracefully
- Configuration: Use runtime.getSetting() for all config
- Testing: Write comprehensive unit and integration tests
- Security: Never hardcode sensitive data
- Documentation: Provide clear usage examples
- Performance: Implement caching and rate limiting
- Logging: Use appropriate log levels
- Versioning: Follow semantic versioning
Getting Help
- Documentation: Check the main ElizaOS docs
- Examples: Study existing plugins in
packages/
- Community: Join the ElizaOS Discord
- Issues: Report bugs on GitHub
Remember: The plugin system is designed to be flexible and extensible. When in doubt, look at existing plugins for patterns and inspiration.
Responses are generated using AI and may contain mistakes.