DocsPlugins

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.

JavaScript
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:

JavaScript
// ✅ 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

JavaScript
server({ bus, cwd, version }, options) {}
FieldTypeWhat it is
busBusThe 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.
cwdstringThe directory the server was started in.
versionstringThe plugin runtime version — the contract this page describes, not the app's release number.

options

JavaScript
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:

JavaScript
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.

JavaScript
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

  1. Boot. The server reads plugin from the global config and imports each specifier in order.
  2. Validate. Modules without a valid {id, server} default export are dropped.
  3. Initialize. server() runs. Throwing here drops the plugin.
  4. Drain tool. If a tool hook exists it is called once, immediately, and its array is cached. It is never called again.
  5. Register. The plugin joins the pipeline in load order. On a duplicate id, the earlier plugin is removed.
  6. Serve. chat.params runs before each model call; the tool.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.