Publish a plugin
Task
Build, package, and publish an Ethos plugin to npm so any user can install it with ethos plugin install <pkg> and the loader registers its tools, hooks, and providers at startup.
Result
A package named ethos-plugin-<name> (or @<scope>/ethos-plugin-<name>) on npm. After ethos plugin install <pkg>, the tool, hook, and personality registrations the plugin declares appear in ethos doctor and become reachable to any personality whose toolset and plugin allowlist include them.
Prereqs
- Node 24+ and pnpm (or npm) on
PATH. - An npm account with publish rights to your scope;
npm whoamireturns your handle. - Ethos checked out or installed locally — you'll import types from
@ethosagent/plugin-sdk(workspace:*if you're inside the monorepo, otherwise a published version). - A clear idea of what the plugin adds: one to five tightly related tools, hooks, or providers. Anything broader belongs in two plugins.
Steps
1. Scaffold the package
mkdir ethos-plugin-myplugin
cd ethos-plugin-myplugin
pnpm init
Set the name, description, the ethos.type manifest field, and the contract major. The loader hard-rejects plugins whose ethos.pluginContractMajor does not match PLUGIN_CONTRACT_MAJOR in @ethosagent/plugin-contract (currently 1).
{
"name": "@yourscope/ethos-plugin-myplugin",
"version": "0.1.0",
"description": "Adds X to Ethos via a single tool.",
"type": "module",
"main": "./dist/index.js",
"exports": {
".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" }
},
"keywords": ["ethos", "ethos-plugin"],
"ethos": {
"type": "plugin",
"pluginContractMajor": 1
},
"peerDependencies": {
"@ethosagent/plugin-sdk": ">=0.1.0",
"@ethosagent/types": ">=0.1.0"
},
"devDependencies": {
"@ethosagent/plugin-sdk": "^0.1.0",
"@ethosagent/types": "^0.1.0",
"tsup": "^8",
"typescript": "^5",
"vitest": "^4"
}
}
Package names starting with ethos-plugin- or scoped under @ethos-plugins/ are auto-discovered when the user runs ethos plugin install. Other names work too, but the user must add them by full id to ~/.ethos/config.yaml.
2. Define the plugin module
Every plugin exports an activate(api) function. The loader instantiates one EthosPluginApi per plugin, calls activate, and tracks every registration so unload() can remove it later.
import type { EthosPlugin, EthosPluginApi } from '@ethosagent/plugin-sdk';
import { defineTool, ok } from '@ethosagent/plugin-sdk/tool-helpers';
const greetTool = defineTool<{ name: string }>({
name: 'greet',
description: 'Greet someone by name.',
toolset: 'hello',
schema: {
type: 'object',
properties: { name: { type: 'string' } },
required: ['name'],
},
async execute({ name }) {
return ok(`Hello, ${name}.`);
},
});
async function onSessionStart(payload: {
sessionId: string;
platform: string;
}): Promise<void> {
console.error(`[myplugin] session ${payload.sessionId} on ${payload.platform}`);
}
export function activate(api: EthosPluginApi): void {
api.registerTool(greetTool);
api.registerVoidHook('session_start', onSessionStart);
}
export function deactivate(): void {
// Tools and hooks are removed by PluginApiImpl.cleanup() automatically.
// Only release external resources here (DB pools, timers, sockets).
}
const plugin: EthosPlugin = { activate, deactivate };
export default plugin;
EthosPluginApi exposes six registration methods: registerTool, registerVoidHook, registerModifyingHook, registerInjector, registerPersonality, and registerContextEngine. See the plugin SDK reference for the full signatures.
3. Add build config
The plugin ships compiled ESM with type declarations. The Ethos loader does a dynamic import() of dist/index.js.
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
clean: true,
});
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"declaration": true,
"outDir": "dist"
},
"include": ["src"]
}
4. Write tests against real registries
Drive activate() against a real PluginApiImpl backed by core registries, then assert that the tool the LLM would see is the one you registered. @ethosagent/plugin-sdk/testing ships mockLLM and createTestRuntime for the end-to-end path.
import {
DefaultHookRegistry,
DefaultPersonalityRegistry,
DefaultToolRegistry,
} from '@ethosagent/core';
import { PluginApiImpl } from '@ethosagent/plugin-sdk';
import type { ContextInjector } from '@ethosagent/types';
import { describe, expect, it } from 'vitest';
import { activate } from '../index';
describe('myplugin', () => {
it('registers the greet tool', async () => {
const injectors: ContextInjector[] = [];
const tools = new DefaultToolRegistry();
const api = new PluginApiImpl('myplugin', {
tools,
hooks: new DefaultHookRegistry(),
injectors,
injectorPluginIds: new Map(),
personalities: new DefaultPersonalityRegistry(),
});
await activate(api);
expect(tools.toDefinitions().map((d) => d.name)).toContain('greet');
});
});
Run pnpm test. A failing test here means the loader will silently skip your plugin in production — fix it before shipping.
5. Document required config
Include a README.md that lists every env var the plugin reads, every tool it adds, and how to add it to ~/.ethos/config.yaml:
plugins:
- "@yourscope/ethos-plugin-myplugin"
Plugins that touch terminal or external network require an entry in the personality's pluginAllowlist. Spell that out so users know which personalities get the tool.
6. Build and publish
pnpm build
ls dist/ # confirm dist/index.js and dist/index.d.ts exist
npm publish --dry-run # inspect what would ship
npm publish --access public
Bump versions per semver: patch for fixes, minor for new tools or hooks, major for breaking changes to a tool's argument schema or to the ethos manifest shape.
Verify
Install the plugin locally and confirm the loader picks it up.
ethos plugin install @yourscope/ethos-plugin-myplugin
ethos doctor
doctor lists the loaded plugins, the tools each registered, and any hook subscriptions. Then run one turn:
ethos chat -q "use the greet tool to say hi to mitesh"
A tool_start / tool_end pair for greet in the stream confirms the plugin reached the agent.
Troubleshoot
Plugin "<name>" declares pluginContractMajor=N, but Ethos's current plugin contract is major=1. — The loader rejected the package before importing it. Bump ethos.pluginContractMajor in package.json to match the current major and republish.
"<name>" has no activate() or register() export — skipping. — The entry point resolved but the module does not expose activate. Confirm dist/index.js exports a named activate function (or a default object with one).
"<name>" blocked by safety scan: .... — The plugin scanner found a red or yellow finding (suspicious shell usage, undeclared network access, prompt-injection patterns in static strings). Move side effects out of top-level code, declare network hosts under ethos.permissions.network in package.json, or split the risky path into a user-confirmed code path.
Plugin loads but the tool never appears to the LLM. — Tools are gated by both the personality toolset and the personality pluginAllowlist. Add the tool name to toolset.yaml and the plugin id to pluginAllowlist for the personalities that should see it.
ECONNREFUSED or EACCES inside activate. — activate runs synchronously during startup; do not open sockets, read large files, or call APIs there. Lazy-init inside the tool's execute.