Plugin anatomy
Exactly one thing is validated when a plugin loads: the module's default export must be an object with a string id and a function server. Everything else is discovered by calling server() and looking at what comes back.
export default {
id: "my-plugin",
async server(input, options) {
return {
/* hook implementations */
}
},
}Fail that check and the module is skipped with one warning — plugin: module has no valid default export {id, server} — and the server carries on without it.
The default export
It must be the default export. A named export is not picked up:
// ✅ loads
export default { id: "my-plugin", server() { return {} } }
// ❌ silently skipped
export const plugin = { id: "my-plugin", server() { return {} } }CommonJS works through ESM interop, so module.exports = { id, server } becomes the default export and loads fine.
id
id is the plugin's identity everywhere in the system:
- It selects the plugin's config block —
plugin_config[id], not the filename or package name. - It labels the plugin in log lines, so a warning tells you which plugin misbehaved.
- It deduplicates. If two loaded plugins share an
id, the last one loaded wins and the earlier one is dropped from the pipeline entirely.
server(input, options)
Called once per plugin, at boot, after the module imports cleanly. It may be sync or async — the loader awaits the result either way.
If server() throws, the plugin is skipped (plugin: server() threw) and the rest load normally. This is the right place to do setup that can fail: read a file, validate config, open a connection. Failing loudly here removes just your plugin, not the server.
input
server({ bus, cwd, version }, options) {}| Field | Type | What it is |
|---|---|---|
bus | Bus | The server event bus. bus.subscribe(type, fn) for one event type, bus.subscribeAll(fn) for all of them. Both return an unsubscribe function. Listener throws are caught and logged. |
cwd | string | The directory the server was started in. |
version | string | The plugin runtime version — the contract this page describes, not the app's release number. |
options
server(input, { config }) {}config is your own block from plugin_config[id], typed as Record<string, unknown> | undefined. It is undefined when no block is configured — always read it defensively:
server(_input, { config }) {
const opts = {
level: "warn",
...(config ?? {}),
}
}Nothing validates the shape of config for you. If a value matters, check it in server() and throw — a plugin that removes itself at boot with a clear message beats one that misbehaves on every turn.
The return value
server() returns a partial map of hook name → function. Return only the hooks you implement; an empty object is valid and means the plugin loads but does nothing.
return {
tool: () => [/* ToolDef[] */],
"chat.params": (params) => params,
"tool.execute.before": (input) => input,
"tool.execute.after": (input) => input,
}Keys are matched by exact string. Unknown keys are ignored without a warning, so "tool.before" or "chat.param" produces a plugin that loads, reports no error, and never runs. Every hook may be sync or async.
Lifecycle, start to finish
- Boot. The server reads
pluginfrom the global config and imports each specifier in order. - Validate. Modules without a valid
{id, server}default export are dropped. - Initialize.
server()runs. Throwing here drops the plugin. - Drain
tool. If atoolhook exists it is called once, immediately, and its array is cached. It is never called again. - Register. The plugin joins the pipeline in load order. On a duplicate
id, the earlier plugin is removed. - Serve.
chat.paramsruns before each model call; thetool.execute.*hooks run around each tool call.
There is no unload, no reload, and no shutdown hook. Editing a plugin file has no effect until the server restarts.