# Plugin Author Guide

How to write a WrongStack plugin: register tools, providers, slash
commands, pipeline middleware, and MCP servers. Tested end-to-end with
real plugin fixtures in `packages/core/tests/plugin/`.

---

## What a plugin is

A plugin is a default-exported `Plugin` object. The host calls
`setup(api)` during boot and `teardown(api)` on shutdown:

```ts
// my-plugin/index.ts
import type { Plugin } from '@wrongstack/core';

const plugin: Plugin = {
  name: 'my-plugin',
  version: '0.1.0',
  description: 'Adds a hello tool',
  apiVersion: '^0.1.0',      // semver range against KERNEL_API_VERSION
  capabilities: { tools: true },

  async setup(api) {
    api.tools.register({
      name: 'hello',
      description: 'Says hello to the user',
      inputSchema: { type: 'object', properties: { who: { type: 'string' } } },
      permission: 'auto',
      mutating: false,
      riskTier: 'safe',
      async execute(input) {
        return { greeting: `Hello, ${input.who ?? 'world'}!` };
      },
    });
  },

  async teardown() {
    // close handles, kill subprocesses, etc.
  },
};

export default plugin;
```

The host loads this from one of:

- `~/.wrongstack/plugins/<name>/` — user-global
- `<projectRoot>/.wrongstack/plugins/<name>/` — project-local
- A path listed in `Config.plugins[<name>].path`

---

## The `api` surface

`setup(api)` receives a scoped `PluginAPI`:

| Field | What it is |
|---|---|
| `api.container` | DI container — bind/resolve `TOKENS.*` |
| `api.pipelines` | All six core pipelines, plus any custom ones |
| `api.events` | `EventBus` for subscribing or emitting |
| `api.tools` | `register / registerAll / wrap / unregister / get / list` tools |
| `api.providers` | Register provider factories |
| `api.mcp` | Start / stop / restart MCP servers |
| `api.slashCommands` | Register `/cmd` handlers |
| `api.config` | The loaded `Config` (read-only snapshot) |
| `api.log` | Scoped `Logger` — entries are tagged with `plugin=<name>` |
| `api.onEvent(name, h)` | Auto-removed-on-teardown event listener |
| `api.onPattern('tool.*', h)` | Wildcard event listener (all matching events) |
| `api.emitCustom(event, payload)` | Emit a custom event on the EventBus |
| `api.onConfigChange(h)` | Called when ConfigStore.update() fires |
| `api.extensions` | Register lifecycle hooks (beforeRun, afterRun, onError, etc.) |
| `api.session` | Append custom events to the JSONL session log |
| `api.metrics` | Record scoped counters/histograms/gauges → Prometheus/OTLP |

Use `onEvent` instead of `events.on(...)` when you want the listener to
disappear with the plugin. Use raw `events.on` only when you need to
explicitly unsubscribe yourself in `teardown`.

---

## Capabilities — declare what you touch

```ts
capabilities: {
  tools: true,
  providers: false,
  slashCommands: true,
  mcp: false,
  pipelines: ['request', 'toolCall'],
}
```

The loader uses this for diagnostics (`wstack plugin list` shows what
each plugin contributes) and for warning when a plugin calls
`api.tools.register()` without declaring `tools: true` (L0-D check).
Capabilities are advisory — they do not block at runtime — but lying is
loud and reviewers will catch it.

---

## Dependencies

```ts
dependsOn: [
  'wstack-auth',                              // string form
  { name: 'wstack-router', version: '^1.2' }, // structured form
],
optionalDeps: [{ name: 'wstack-cache', version: '^0.5' }],
conflictsWith: ['wstack-other-router'],
```

The loader topologically sorts plugins by `dependsOn`, rejects cycles
with a clear error, and surfaces version mismatches before calling
`setup`. Missing `optionalDeps` are silently skipped.

---

## Config schema

Plugin options come from either `plugins[].options` or
`extensions[<name>]`. `extensions[<name>]` wins when both are present. Declare
a `configSchema` and the loader validates the merged options before calling
`setup`:

```ts
configSchema: {
  type: 'object',
  properties: {
    endpoint: { type: 'string' },
    timeoutMs: { type: 'integer', minimum: 1, maximum: 60_000 },
  },
  required: ['endpoint'],
},
```

Reach validated options at runtime via `api.config.extensions[name]`. A
validation failure aborts plugin load with `error.path` pointing at the
offending field.

---

## Patterns by concern

### Register a tool

```ts
api.tools.register({
  name: 'greet',
  description: 'Greet someone',
  inputSchema: { type: 'object', properties: { name: { type: 'string' } } },
  permission: 'auto',
  mutating: false,
  riskTier: 'safe',
  async execute(input) {
    return { msg: `Hi, ${input.name}` };
  },
});
```

See [tool-author-guide.md](tool-author-guide.md) for the full Tool contract
(streaming, cleanup, permission semantics).

### Register a provider factory

```ts
import { WireFormatProvider } from '@wrongstack/providers';
import { myWireFormat } from './my-wire-format.js';

api.providers.register({
  type: 'my-llm',
  family: 'openai-compatible',
  create: (cfg) => {
    const c = cfg as { apiKey: string; baseUrl?: string };
    return new WireFormatProvider(myWireFormat, {
      apiKey: c.apiKey,
      baseUrl: c.baseUrl,
    });
  },
});
```

See [provider-author-guide.md](provider-author-guide.md) for writing the
`myWireFormat` config and for cases that genuinely need a custom provider
class.

### Add middleware to a pipeline

```ts
api.pipelines.request.use({
  name: 'inject-headers',
  owner: 'my-plugin',           // shown in /diag, used by host error policy
  handler: async (req, next) => {
    (req as { headers?: Record<string, string> }).headers = {
      ...(req as any).headers,
      'x-tenant': api.config.extensions?.['my-plugin']?.tenant ?? 'default',
    };
    return next(req);
  },
});
```

Throwing from a handler bubbles up unless the host installed a boundary
(`Pipeline.setErrorHandler`). The CLI installs a default boundary at boot
that surfaces the failure to `/diag` but doesn't crash the agent (L1-F).

### Subscribe to events

```ts
api.onEvent('tool.executed', (e) => {
  api.log.info(`${e.name} ran in ${e.durationMs}ms`);
});
```

The listener is removed when the plugin is unloaded. For long-lived
external state, do the cleanup in `teardown`.

### Register a slash command

```ts
api.slashCommands.register({
  name: 'tenant',
  description: 'Switch active tenant',
  async execute({ args, ctx }) {
    ctx.meta.tenant = args.join(' ') || 'default';
    return { message: `Tenant set to ${ctx.meta.tenant}` };
  },
});
```

### Start an MCP server

```ts
await api.mcp.start({
  name: 'my-mcp',
  transport: 'stdio',
  command: 'npx',
  args: ['-y', '@me/my-mcp-server'],
  enabled: true,
});
```

The registry handles reconnect-with-backoff and tools/list_changed
invalidation; you don't need to manage either yourself.

### Agent lifecycle hooks

Plugins can hook into every phase of the agent run via `api.extensions.register()`:

```ts
api.extensions.register({
  name: 'my-plugin-hooks',
  beforeRun: async (ctx, input) => { /* before iteration loop starts */ },
  afterRun: async (ctx, result) => { /* after run finishes */ },
  beforeIteration: async (ctx, idx) => { /* start of each iteration */ },
  afterIteration: async (ctx, idx) => { /* end of each iteration */ },
  onError: async (ctx, err, phase, idx) => {
    return { action: 'retry' }; // or 'fail' | 'continue'
  },
  wrapProviderRunner: async (ctx, req, inner) => {
    // wrap/replace the provider call
    return inner(ctx, req);
  },
  beforeToolExecution: async (ctx, toolUses) => {
    // filter or reorder tools before execution
    return toolUses;
  },
  afterToolExecution: async (ctx, outputs) => { /* inspect results */ },
});
```

Each hook is optional. Failures are caught and logged — one bad hook
never crashes the agent.

### Inject context into the system prompt

```ts
api.registerSystemPromptContributor(async (ctx) => [
  { type: 'text', text: '## My Plugin Context\nCurrent state: ...' },
]);
```

Contributors fire on every `build()` call, so the prompt stays current
across turns. The host's `DefaultSystemPromptBuilder` must have
`contributors` wired (the CLI does this automatically when plugins pass
`api.extensions`).

### Write custom session events

Plugins can persist events to the JSONL session log:

```ts
await api.session.append({
  type: 'my-plugin:cache_hit',
  ts: new Date().toISOString(),
  key: 'user-123',
  latencyMs: 2.4,
});
```

Custom events are serialized verbatim next to built-in events
(`user_input`, `tool_result`, etc.). The `ts` field is required;
everything else is free-form.

### Record metrics (Prometheus / OTLP)

```ts
api.metrics.counter('cache_hits', 1, { tier: 'l1' });
api.metrics.histogram('cache_latency_ms', 42.5);
api.metrics.gauge('cache_size', 142);
```

Metric names are auto-prefixed with `plugin.<pluginName>.` so they
don't collide across plugins. Values flow to the host's `MetricsSink`
— `InMemoryMetricsSink` (CLI), Prometheus, or OTLP. A noop sink is
used when metrics are disabled.

### Subscribe to config changes

```ts
api.onConfigChange((next, prev) => {
  this.ttl = next.plugins?.['my-plugin']?.ttl ?? 3600;
});
```

Fires when `ConfigStore.update()` is called (e.g., via `/config` slash
command). Use this instead of caching `api.config` values at `setup` time.

### Decorate (wrap) existing tools

```ts
api.tools.wrap('bash', (original) => ({
  ...original,
  permission: 'confirm',          // tighter permission
  riskTier: 'destructive',        // raw shell is powerful; policy gates clearly destructive commands unless --yolo-destructive
  async execute(input, ctx, opts) {
    api.log.info('bash called with', input);
    return original.execute(input, ctx, opts);
  },
}));
```

Multiple wraps stack — each wrapper receives the output of the previous.

### Emit custom events

```ts
api.emitCustom('my-plugin:state_changed', { state: 'active' });
```

Custom events flow through the same `EventBus` as built-in events.
Use `pluginName:eventName` convention to avoid collisions. Other
plugins (and the host) can subscribe via `events.on` or `api.onEvent`.

### Wildcard event subscriptions

```ts
api.onPattern('tool.*', (name, payload) => {
  api.log.info(`Tool event: ${name}`, payload);
});
```

Matches `tool.started`, `tool.executed`, `tool.progress`, etc.
`'*'` matches every event. `onPattern` listeners are auto-removed on
teardown.

### Bulk tool registration

```ts
api.tools.registerAll([toolA, toolB, toolC], 'my-plugin');
```

Conflicts are silently skipped. Use `registerAllOrThrow` on the raw
`ToolRegistry` if you need strict registration.

### Tool categories

```ts
api.tools.register({
  name: 'cache_stats',
  category: 'Cache',         // ← groups tools in the system prompt
  // ...
});
```

Tools are listed by category in the system prompt:
```
### Cache
- **cache_stats** — Show cache statistics
- **cache_clear** — Clear the cache
```

### Plugin defaults and health

```ts
const plugin: Plugin = {
  name: 'my-plugin',
  defaultConfig: { ttl: 3600, maxEntries: 100 },  // merged before setup
  configSchema: { /* validates merged config */ },
  async health() {
    return { ok: this.connected, message: 'Redis: connected' };
  },
  // ...
};
```

`defaultConfig` is shallow-merged with user config before `configSchema`
validation. `health()` is exposed via `/diag plugins` for diagnostics.

### Capabilities enforcement (strict mode)

Set `enforceCapabilities: true` in `loadPlugins` options to make the
loader THROW (instead of logging a warning) when a plugin calls an API
method not declared in its `capabilities`:

```ts
await loadPlugins(plugins, {
  enforceCapabilities: true,  // strict mode — lying = load failure
  // ...
});
```

---

## Teardown contract

`teardown(api)` runs on:

- `SIGINT` from the user
- Natural process exit
- When the loader unloads the plugin individually (rare)

Inside it: stop intervals, kill subprocesses, close handles, flush
buffered writes. Errors thrown from `teardown` are logged but do not
prevent other plugins from tearing down. Make every cleanup
best-effort.

```ts
async teardown(api) {
  clearInterval(this.heartbeat);
  await this.subprocess?.kill();
  api.log.info('shut down cleanly');
},
```

For resources tied to a single agent run (not the whole plugin lifetime),
use `ctx.registerAbortHook` from inside `Tool.execute` instead. See the
JSDoc on [`Tool.cleanup`](../packages/core/src/types/tool.ts) for the rule.

---

## Testing your plugin

Plugins are plain TS modules with a default export. Test them by
constructing a real (or stub) `PluginAPI` and asserting the side
effects:

```ts
import { describe, it, expect, vi } from 'vitest';
import { Container, EventBus, ToolRegistry } from '@wrongstack/core';
import myPlugin from '../src/index.js';

describe('my-plugin', () => {
  it('registers the greet tool', async () => {
    const tools = new ToolRegistry();
    const api: any = {
      container: new Container(),
      events: new EventBus(),
      tools,
      log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
      config: {},
      onEvent: () => () => {},
      pipelines: {},
      providers: { register: vi.fn(), create: vi.fn(), list: () => [] },
      mcp: { start: vi.fn(), stop: vi.fn(), restart: vi.fn(), list: () => [] },
      slashCommands: { register: vi.fn(), unregister: vi.fn(), get: vi.fn(), list: () => [] },
    };

    await myPlugin.setup(api);
    expect(tools.get('greet')).toBeDefined();
  });
});
```

For richer integration tests, use the fixtures in
[`packages/core/tests/plugin/`](../packages/core/tests/plugin/) which
exercise the loader, capability warning, teardown, and dependency cycle
detection paths.

---

## Common pitfalls

- **`apiVersion` mismatch.** The loader compares your declared range against
  `KERNEL_API_VERSION`. Off-by-one bumps fail loud — fix the range.
- **Mutating `api.config`.** Treat it as a frozen snapshot. To react to
  config changes at runtime, subscribe via `api.container.resolve(TOKENS.ConfigStore).watch(...)`.
- **Forgetting `teardown` for sockets / timers.** The process won't exit cleanly.
- **Throwing from `setup`.** Aborts the entire CLI boot. If your plugin can't
  function with the current config, log and return early — don't throw.
- **Registering inside a pipeline handler.** Registries are not append-safe
  during iteration. Do registrations only in `setup`.

## Security Considerations for Plugin Authors

Plugins run with significant power (they can register tools, wrap existing tools,
register slash commands, contribute pipelines, and load MCP servers).

### Security checklist for plugins

- [ ] **Capability declaration** — Be honest in `capabilities`. Over-declaring is better than under-declaring.
- [ ] **Tool mutation** — External (non-official) plugins can only `wrap`/`unregister` tools they solely own. Attempting to downgrade a built-in will throw (see `packages/core/src/plugin/api.ts`).
- [ ] **MCP servers** — Loading an MCP server means proxying arbitrary tools from that server. This has major subagent impact (most `mcp__*` tools are blocked for subagents by default).
- [ ] **Pipeline middleware** — Middleware runs on every request/response. Be extremely careful with mutation and performance.
- [ ] **Secret access** — Plugins should almost never need direct access to the secret vault. Use the provided abstractions.

Adding a plugin that grants new powerful capabilities (especially shell, arbitrary FS write, or MCP proxying) should be treated as a security-sensitive change.

See `docs/plans/security-hardening-2026-06.md` (P2) and `SECURITY.md`.

---

## Reference

- Plugin type: [`packages/core/src/types/plugin.ts`](../packages/core/src/types/plugin.ts)
- Loader: [`packages/core/src/plugin/loader.ts`](../packages/core/src/plugin/loader.ts)
- Test fixtures: [`packages/core/tests/plugin/`](../packages/core/tests/plugin/)
