packages/docs/plugins/local-plugins.md
This guide covers developing plugins locally without publishing to npm -- custom integrations, private plugins, rapid prototyping, and ejecting upstream plugins for modification.
Maintainer note: this document is for runtime/user plugin paths under ~/.eliza/plugins/*. Eliza source-checkout development of first-party packages uses the repo-local workspaces under eliza/plugins/* and eliza/packages/* by default; the old sibling-checkout flow is no longer the primary path in this repo.
Eliza discovers plugins from three locations under the state directory (~/.eliza/ by default):
Upstream plugins cloned locally for modification:
~/.eliza/plugins/ejected/<plugin-name>/
These are created by the eject system (see Ejecting Upstream Plugins). Each subdirectory is a full git repo with editable source.
Plugins installed at runtime via the plugin manager or CLI:
~/.eliza/plugins/installed/<sanitised-name>/
Each plugin gets an isolated directory with its own package.json and node_modules/. The installer creates a minimal { "private": true, "dependencies": {} } package.json, then runs bun add <package> (or npm install as fallback) inside that directory.
Hand-written plugins placed directly in the custom directory:
~/.eliza/plugins/custom/<your-plugin>/
Any subdirectory here with a package.json is auto-discovered at startup. This is the simplest way to add a local plugin -- just drop it in and restart.
Additional directories can be specified in eliza.json:
{
"plugins": {
"load": {
"paths": [
"~/shared-plugins",
"/opt/team-plugins"
]
}
}
}
Each directory is scanned the same way as plugins/custom/ -- subdirectories with a package.json are treated as plugins.
~/.eliza/
├── eliza.json # Main config file
└── plugins/
├── ejected/ # Git-cloned upstream plugins for editing
│ └── plugin-telegram/
│ ├── .upstream.json
│ ├── package.json
│ ├── src/
│ └── dist/
├── installed/ # Runtime-installed plugins (managed by plugin-installer)
│ └── _elizaos_plugin-twitter/
│ ├── package.json
│ └── node_modules/
└── custom/ # Hand-written drop-in plugins
└── my-plugin/
├── package.json
├── src/
└── dist/
When multiple sources provide the same plugin name, Eliza uses this precedence (highest first):
| Priority | Source | Path | Use case |
|---|---|---|---|
| 1 | Ejected | ~/.eliza/plugins/ejected/ | Modifying upstream plugin source |
| 2 | Workspace override | Internal dev mechanism | Eliza contributors only |
| 3 | Official npm (with install record) | node_modules/@elizaos/plugin-* | Standard @elizaos/* plugins prefer bundled copies |
| 4 | User-installed (with install record) | ~/.eliza/plugins/installed/ | Third-party plugins installed at runtime |
| 5 | Local @eliza | src/plugins/ (compiled dist) | Built-in Eliza plugins |
| 6 | npm fallback | import(name) | Last resort dynamic import |
Custom/drop-in plugins are merged into the install records before resolution, so they participate in priorities 3-4 depending on their package name.
The deny list (plugins.deny in eliza.json) takes absolute precedence -- denied plugins are never loaded regardless of source.
mkdir -p ~/.eliza/plugins/custom/my-plugin/src
cd ~/.eliza/plugins/custom/my-plugin
cat > package.json << 'EOF'
{
"name": "my-plugin",
"version": "1.0.0",
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"dev": "tsc --watch"
},
"dependencies": {
"@elizaos/core": "alpha"
}
}
EOF
cat > tsconfig.json << 'EOF'
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
EOF
// src/index.ts
import type { Plugin, Action, Provider } from "@elizaos/core";
const greetAction: Action = {
name: "GREET_USER",
similes: ["SAY_HELLO", "WELCOME"],
description: "Greets the user by name",
validate: async () => true,
handler: async (runtime, message, state, options) => {
const name = options?.parameters?.name ?? "friend";
return {
success: true,
text: `Hello, ${name}! Welcome to Eliza.`,
};
},
parameters: [
{
name: "name",
description: "Name of the person to greet",
required: false,
schema: { type: "string", default: "friend" },
},
],
};
const statusProvider: Provider = {
name: "myPluginStatus",
get: async (runtime, message, state) => {
return {
text: "My plugin is active and running.",
};
},
};
const plugin: Plugin = {
name: "my-plugin",
description: "A local development plugin",
actions: [greetAction],
providers: [statusProvider],
init: async (config, runtime) => {
runtime.logger?.info("[my-plugin] Initialized successfully");
},
};
export default plugin;
cd ~/.eliza/plugins/custom/my-plugin
bun install
bun run build
# If running in terminal
eliza start
# Or restart via the agent chat
# Type: /restart
On startup, you should see in the logs:
[eliza] Discovered 1 custom plugin(s): my-plugin
Control which plugins load via eliza.json:
{
"plugins": {
"allow": ["my-plugin", "telegram", "@elizaos/plugin-discord"],
"deny": ["@elizaos/plugin-shell"]
}
}
When allow is set, only listed plugins load (plus core plugins). The deny list always wins -- a denied plugin is never loaded even if it appears in allow.
Plugin names can be specified as:
@elizaos/plugin-telegramtelegram (resolves to @elizaos/plugin-telegram)my-plugin (matches the name field in your plugin's package.json)Configure individual plugins under plugins.entries:
{
"plugins": {
"entries": {
"my-plugin": {
"enabled": true,
"config": {
"apiEndpoint": "https://api.example.com",
"maxRetries": 3
}
},
"telegram": {
"enabled": false
}
}
}
}
Setting enabled: false on an entry prevents that plugin from loading, even if auto-enable logic would otherwise activate it.
Eliza automatically enables plugins based on your configuration:
connectors, its plugin is auto-enabled.ANTHROPIC_API_KEY), the corresponding provider plugin is auto-enabled.features, its plugin is auto-enabled.This happens at startup via applyPluginAutoEnable() and does not modify your config file -- it only affects the in-memory plugin set for that session.
The plugin installer (plugin-installer.ts) handles runtime installation of plugins from the registry.
bun add (preferred) or npm install (fallback) into an isolated directory at ~/.eliza/plugins/installed/<sanitised-name>/git clone if the npm install failseliza.json under plugins.installsThe installer sanitises package names for directory names by replacing non-alphanumeric characters (except ., -, _) with underscores. For example, @elizaos/plugin-x becomes _elizaos_plugin-twitter.
Each installed plugin is tracked in eliza.json:
{
"plugins": {
"installs": {
"@elizaos/plugin-x": {
"source": "npm",
"spec": "@elizaos/[email protected]",
"installPath": "/Users/you/.eliza/plugins/installed/_elizaos_plugin-twitter",
"version": "1.0.0",
"installedAt": "2026-02-19T12:00:00.000Z"
}
}
}
}
The installer uses a serialisation lock to prevent concurrent installs from corrupting the config. Multiple install requests are queued and executed sequentially.
Uninstallation removes the plugin directory from disk and deletes its record from eliza.json. Core/built-in plugins cannot be uninstalled. The uninstaller refuses to delete directories outside ~/.eliza/plugins/installed/ as a safety measure.
The eject system lets you clone an upstream plugin's source, modify it, and have Eliza load your local copy instead of the npm package.
eject the telegram plugin so I can edit its source
git clone --branch 1.x --depth 1 \
https://github.com/elizaos-plugins/plugin-telegram.git \
~/.eliza/plugins/ejected/plugin-telegram
cd ~/.eliza/plugins/ejected/plugin-telegram
bun install
bun run build
Each ejected plugin has a .upstream.json at its root:
{
"$schema": "eliza-upstream-v1",
"source": "github:elizaos-plugins/plugin-telegram",
"gitUrl": "https://github.com/elizaos-plugins/plugin-telegram.git",
"branch": "1.x",
"commitHash": "093613e...",
"ejectedAt": "2026-02-19T08:00:00Z",
"npmPackage": "@elizaos/plugin-telegram",
"npmVersion": "1.6.4",
"lastSyncAt": null,
"localCommits": 0
}
cd ~/.eliza/plugins/ejected/plugin-telegram
git fetch origin
git pull --rebase origin 1.x
bun run build
Or via agent chat: sync the ejected telegram plugin
Remove the ejected directory to fall back to the npm version:
rm -rf ~/.eliza/plugins/ejected/plugin-telegram
# Restart eliza -- it will load the npm version again
Or via agent chat: reinject the telegram plugin
The standard development loop for local plugins:
# Terminal 1: Watch and rebuild on changes
cd ~/.eliza/plugins/custom/my-plugin
bun run dev # runs tsc --watch
# Terminal 2: Run eliza
eliza start
After making changes, the TypeScript watcher rebuilds dist/ automatically. You still need to restart the agent to pick up the new build:
/restart in the agent chat, oreliza start againChat with the agent and trigger your action:
You: Greet me as Alice
Agent: Hello, Alice! Welcome to Eliza.
Check the logs for your plugin's initialization message and any debug output.
If you prefer manual builds:
cd ~/.eliza/plugins/custom/my-plugin
bun run build && eliza start
For rapid prototyping, you can point main at the TypeScript source:
{
"main": "src/index.ts"
}
Eliza's runtime can import TypeScript files directly in dev mode. Switch to dist/index.js before distributing.
Load a plugin from any path using eliza.json:
{
"plugins": {
"entries": {
"my-plugin": {
"enabled": true,
"path": "~/projects/my-plugin/dist"
}
}
}
}
Path supports tilde expansion (~/) and both relative and absolute paths. This is useful when your plugin lives outside the standard plugin directories.
LOG_LEVEL=debug to see plugin loading, discovery, and initialization logsLoading plugin: your-plugin-name# List loaded plugins
curl http://localhost:18789/api/plugins
# Search the registry
curl http://localhost:18789/api/registry/search?q=my-plugin
ELIZA_STATE_DIR:# Instance with your dev plugin
ELIZA_STATE_DIR=./state-dev eliza start
# Instance with production plugins
ELIZA_STATE_DIR=./state-prod eliza start
Eliza reads the log level from LOG_LEVEL env var or logging.level in config. If LOG_LEVEL is set in the environment, it takes precedence over the config value.
# Verbose logging via environment variable
LOG_LEVEL=debug eliza start
Or set it in eliza.json:
{
"logging": {
"level": "debug"
}
}
Available levels: debug, info, warn, error (default).
Use the runtime logger inside your plugin:
init: async (config, runtime) => {
runtime.logger?.debug("[my-plugin] Detailed debug info", { config });
runtime.logger?.info("[my-plugin] Plugin initialized");
runtime.logger?.warn("[my-plugin] Something looks off");
runtime.logger?.error("[my-plugin] Something failed", { error: "details" });
},
Enable source maps for readable stack traces pointing to your TypeScript source:
NODE_OPTIONS="--enable-source-maps" eliza start
Make sure "sourceMap": true is set in your tsconfig.json (included in the template above).
Create .vscode/launch.json in your project:
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug Eliza",
"runtimeExecutable": "bun",
"runtimeArgs": ["run", "eliza", "start"],
"cwd": "${workspaceFolder}",
"env": {
"LOG_LEVEL": "debug"
},
"console": "integratedTerminal",
"skipFiles": ["<node_internals>/**"]
}
]
}
Set breakpoints in your plugin's TypeScript files and launch with F5.
Plugin not discovered at startup:
~/.eliza/plugins/custom/ (not nested deeper)package.json exists and has a name fieldmain in package.json points to an existing file[eliza] Discovered N custom plugin(s) in the startup logsPlugin discovered but fails to load:
bun run build -- the dist/ directory may be missingname and descriptionLOG_LEVEL=debug eliza startPlugin denied or filtered out:
plugins.deny in eliza.json -- your plugin name may be listedplugins.allow is set, your plugin must be in the allowlistplugins.entries.<name>.enabled is not set to falseTypeScript compilation errors:
cd ~/.eliza/plugins/custom/my-plugin
bun run tsc --noEmit # Type-check without emitting
These environment variables affect plugin paths and behavior. They are defined in eliza/packages/agent/src/config/paths.ts.
| Variable | Default | Description |
|---|---|---|
ELIZA_STATE_DIR | ~/.eliza | Override the state directory. Changes where plugins, config, and credentials are stored. |
ELIZA_CONFIG_PATH | ~/.eliza/eliza.json | Override the config file path directly. |
ELIZA_OAUTH_DIR | ~/.eliza/credentials | Override the OAuth credentials directory. |
LOG_LEVEL | error | Set log verbosity: debug, info, warn, error. |
ELIZA_DISABLE_WORKSPACE_PLUGIN_OVERRIDES | unset | Set to 1 to disable workspace plugin overrides (dev-only mechanism). |
ELIZA_WORKSPACE_ROOT | unset | Override the workspace root for plugin resolution. When set, only this directory is searched for local plugin sources. |
When ELIZA_STATE_DIR is set, all derived paths change accordingly:
$ELIZA_STATE_DIR/plugins/installed/, $ELIZA_STATE_DIR/plugins/custom/, $ELIZA_STATE_DIR/plugins/ejected/$ELIZA_STATE_DIR/eliza.json (unless ELIZA_CONFIG_PATH is also set)$ELIZA_STATE_DIR/models/When your plugin is ready for distribution:
{
"name": "@yourorg/plugin-my-feature",
"version": "1.0.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": ["dist"],
"scripts": {
"build": "tsc",
"prepublishOnly": "bun run build"
},
"peerDependencies": {
"@elizaos/core": ">=2.0.0"
}
}
cd ~/.eliza/plugins/custom/my-plugin
bun run build
npm pack # Preview what gets published
npm publish --access public
Once published, install through the agent chat or directly in config:
{
"plugins": {
"allow": ["@yourorg/plugin-my-feature"]
}
}
Remove the local copy from ~/.eliza/plugins/custom/ to avoid loading both versions.