# Creating a Custom Plugin

This recipe explains how to create a custom plugin for Launchpad. Plugins are the primary extension point for the Launchpad controller — they receive a context on startup and can respond to commands, manage long-lived resources, and expose state to the system.

## Overview

Plugins in Launchpad:

- Are registered in the `plugins` array of your Launchpad config
- Receive a `PluginContext` during `setup()` with access to logging, events, and state management
- Can optionally handle commands dispatched to them
- Can optionally expose state to status output and other plugins
- Can optionally run startup work once every plugin has been set up
- Can optionally perform cleanup on shutdown

## Basic Plugin Structure

A plugin is created with `definePlugin` from `@bluecadet/launchpad/utils/plugin-interfaces`:

```typescript
import { definePlugin } from '@bluecadet/launchpad/utils/plugin-interfaces';
import { okAsync } from 'neverthrow';

export const myPlugin = definePlugin({
  name: 'my-plugin',
  setup(ctx) {
    ctx.logger.info('My plugin is starting up!');
    return okAsync({});
  }
});
```

Add it to your Launchpad config:

```typescript
import { defineConfig } from '@bluecadet/launchpad/cli';
import { myPlugin } from './my-plugin.js';

export default defineConfig({
  plugins: [myPlugin]
});
```

## Typed Plugin Options

If your plugin takes options, write them as a documented interface and bind your Zod schema to it with `configSchema`. Editors show the interface’s comments when users fill in options in `launchpad.config.ts`. A type inferred with `z.input<typeof schema>` loses those comments once the plugin is compiled.

```typescript
import { configSchema } from '@bluecadet/launchpad/utils/config-schema';
import { definePlugin } from '@bluecadet/launchpad/utils/plugin-interfaces';
import { okAsync } from 'neverthrow';
import { z } from 'zod';

export interface GreeterOptions {
  /** Name to greet on startup. Defaults to `"world"`. */
  name?: string;
}

const greeterOptionsSchema = configSchema<GreeterOptions>()(
  z.object({ name: z.string().default('world') })
);

export function greeter(options: GreeterOptions = {}) {
  const { name } = greeterOptionsSchema.parse(options);
  return definePlugin({
    name: 'greeter',
    setup(ctx) {
      ctx.logger.info(`Hello, ${name}!`);
      return okAsync({});
    }
  });
}
```

`configSchema` fails type-checking if the schema’s input type stops matching the interface, so the two can’t drift apart.

## Receiving Commands

Plugins that handle commands should declare them explicitly in `manifest.commands` and implement `executeCommand()`. The controller only dispatches commands that have been registered through the manifest.

> [!TIP]
> For production plugins, validate incoming commands with a [Zod](https://zod.dev) schema by attaching the schema to `manifest.commands` and validating again inside `executeCommand()` as needed.

```typescript
import { definePlugin } from '@bluecadet/launchpad/utils/plugin-interfaces';
import { errAsync, okAsync } from 'neverthrow';
import { z } from 'zod';

type GreetCommand = { type: 'greet-plugin.greet'; name: string };

const greetCommandSchema = z.object({
  type: z.literal('greet-plugin.greet'),
  name: z.string(),
}).strict();

export const greetPlugin = definePlugin({
  name: 'greet-plugin',
  manifest: {
    commands: [
      {
        id: 'greet-plugin.greet',
        parser: greetCommandSchema,
      },
    ],
  },
  setup(ctx) {
    return okAsync({
      executeCommand(command: GreetCommand) {
        const parsed = greetCommandSchema.safeParse(command);
        if (!parsed.success) {
          return errAsync(new Error(`Invalid command: ${parsed.error.message}`));
        }

        ctx.logger.info(`Hello, ${parsed.data.name}!`);
        return okAsync(undefined);
      }
    });
  }
});
```

### Declaring Startup and Shutdown Workflows

Plugins should declare commands, while hosts decide when those commands run:

```typescript
export default defineConfig({
  plugins: [greetPlugin],
  workflows: {
    start: [{ type: 'greet-plugin.greet', name: 'World' }],
  },
});
```

> [!IMPORTANT]
> Launchpad no longer infers command ownership from the command type prefix. Plugins declare commands in `manifest.commands`; hosts declare orchestration in `workflows` and hand it to the controller.

## Managing State

Plugins can expose state to the system by calling `ctx.updateState()`. The controller aggregates plugin states and broadcasts patches to connected IPC clients.

```typescript
import { definePlugin } from '@bluecadet/launchpad/utils/plugin-interfaces';
import { okAsync } from 'neverthrow';

type CounterCommand = { type: 'counter-plugin.increment' };
type CounterState = { count: number };

export const counterPlugin = definePlugin({
  name: 'counter-plugin',
  manifest: {
    commands: [{ id: 'counter-plugin.increment' }],
  },
  setup(ctx) {
    // Initialize state
    ctx.updateState((_draft: CounterState) => {
      return { count: 0 } as unknown as void;
    });

    return okAsync({
      executeCommand(command: CounterCommand) {
        if (command.type === 'counter-plugin.increment') {
          ctx.updateState((draft: CounterState) => {
            draft.count += 1;
          });
        }
        return okAsync(undefined);
      }
    });
  }
});
```

> [!NOTE]
> Call `ctx.updateState()` with a complete initial value at the top of `setup()` to establish state. For subsequent updates, mutate the draft directly (Immer-style).

## Contributing Status Output

Plugins that want to appear in `launchpad status` can add a `summarize()` hook to their plugin config. The hook receives the aggregated Launchpad state and returns a `Section`, or `null` when the plugin has no status to show.

```typescript
import { definePlugin } from '@bluecadet/launchpad/utils/plugin-interfaces';
import type { LaunchpadState, Section } from '@bluecadet/launchpad-utils/types';
import { okAsync } from 'neverthrow';

type CounterState = { count: number };

export const counterPlugin = definePlugin({
  name: 'counter-plugin',
  setup(ctx) {
    ctx.updateState(() => ({ count: 0 }));
    return okAsync({});
  },
  summarize(state: LaunchpadState): Section | null {
    const counterState = state.plugins['counter-plugin'] as CounterState | undefined;
    if (!counterState) return null;

    return {
      name: 'counter-plugin',
      title: 'Counter',
      rows: [{ type: 'kv', label: 'Count', value: String(counterState.count) }],
    };
  },
});
```

## Reacting to Events

The `eventBus` provides cross-plugin event communication. Listen for events emitted by other plugins or the system:

```typescript
import { definePlugin } from '@bluecadet/launchpad/utils/plugin-interfaces';
import { okAsync } from 'neverthrow';

export const listenerPlugin = definePlugin({
  name: 'listener-plugin',
  setup(ctx) {
    ctx.eventBus.on('content:fetch:start', (data) => {
      ctx.logger.info('Content fetch started!', data);
    });

    return okAsync({});
  }
});
```

## Startup Work That Needs Other Plugins

`setup()` runs sequentially in registration order, so a plugin that emits an event during its own setup is invisible to every plugin registered after it. Implement `ready()` for startup work that depends on the rest of the system being present — the controller calls it on every registered plugin once all of them have been set up, sequentially in registration order: each plugin’s `ready()` completes before the next one’s starts.

```typescript
import { definePlugin } from '@bluecadet/launchpad/utils/plugin-interfaces';
import { okAsync } from 'neverthrow';

export const announcerPlugin = definePlugin({
  name: 'announcer-plugin',
  setup(ctx) {
    return okAsync({
      ready() {
        // Every plugin — including transports that subscribe during their own setup —
        // is listening by now.
        ctx.eventBus.emit('announcer-plugin.ready', { at: Date.now() });
        return okAsync(undefined);
      }
    });
  }
});
```

A `ready()` that fails is logged and contained: it never fails startup, and the remaining plugins still become ready. Plugins registered after the ready phase has run (a host registering a plugin at runtime) get their `ready()` immediately after `setup()`.

## Cleanup on Shutdown

Implement `disconnect()` to release long-lived resources (connections, child processes, timers) when Launchpad shuts down. The controller calls `disconnect()` after the abort signal fires.

```typescript
import { definePlugin } from '@bluecadet/launchpad/utils/plugin-interfaces';
import { okAsync } from 'neverthrow';

export const resourcePlugin = definePlugin({
  name: 'resource-plugin',
  setup(ctx) {
    let interval: ReturnType<typeof setInterval> | null = null;

    interval = setInterval(() => {
      ctx.logger.debug('Tick');
    }, 5000);

    return okAsync({
      disconnect() {
        if (interval) {
          clearInterval(interval);
          interval = null;
        }
        ctx.logger.info('Resource plugin disconnected');
        return okAsync(undefined);
      }
    });
  }
});
```

## The Plugin Context

The `PluginContext` passed to `setup()` provides:

| Property             | Description                                                                                         |
| -------------------- | --------------------------------------------------------------------------------------------------- |
| `logger`             | Logger instance for structured output                                                               |
| `eventBus`           | Event bus for cross-plugin communication                                                            |
| `abortSignal`        | Fires when Launchpad is shutting down                                                               |
| `cwd`                | Working directory                                                                                   |
| `mode`               | Which mode the controller is running in (`"task" \| "persistent"`)                                  |
| `getStatusSnapshot`  | Build the display-oriented status snapshot, aggregating every plugin’s `summarize()`. Use sparingly |
| `dispatchCommand`    | Dispatch a registered command to another plugin                                                     |
| `updateState`        | Update this plugin’s state slice                                                                    |
| `getGlobalState`     | Read the full aggregated system state                                                               |
| `onGlobalStatePatch` | Subscribe to state patches across the system                                                        |

## Async Initialization

If your plugin needs to perform async work during setup (e.g. connecting to a database), do it inside `setup()` and return the result wrapped in `ResultAsync`:

```typescript
import { definePlugin } from '@bluecadet/launchpad/utils/plugin-interfaces';
import { ResultAsync, okAsync } from 'neverthrow';

type DbCommand = { type: 'db-plugin.ping' };

export const dbPlugin = definePlugin({
  name: 'db-plugin',
  manifest: {
    commands: [{ id: 'db-plugin.ping' }],
  },
  setup(ctx) {
    return ResultAsync.fromPromise(
      connectToDatabase(),
      (e) => new Error(`Failed to connect: ${e}`)
    ).map((db) => ({
      executeCommand(command: DbCommand) {
        // use db here
        return okAsync(undefined);
      },
      disconnect() {
        return ResultAsync.fromPromise(db.close(), (e) => e as Error);
      }
    }));
  }
});
```

## Best Practices

1. **Choose a unique name**: Plugin names appear in logs and state aggregation
2. **Declare handled commands in `manifest.commands`**: command execution is explicit and controller-owned
3. **Keep startup behavior in host workflows**: plugins expose commands, and hosts decide when to run them
4. **Use `summarize()`** for `launchpad status` output instead of mutating controller-owned status registries
5. **Return `ResultAsync`** from `setup()` — wrap async errors with `ResultAsync.fromPromise` rather than throwing
6. **Use `ready()` for cross-plugin startup work** — anything emitted during `setup()` is missed by plugins registered later
7. **Implement `disconnect()`** for any plugin that holds open handles or long-lived connections
8. **Use `abortSignal`** to cancel in-flight async work rather than ignoring it
9. **Prefer `dispatchCommand` over direct references** for cross-plugin coordination to keep plugins decoupled
10. **Never call `process.exit()`** from plugin code — emit a `system:shutdown` event via the event bus if the plugin needs to signal termination, and let the host process decide when to exit
11. **Never throw from plugin methods** — always return `errAsync()` or `err()` instead. Functions that return `ResultAsync` must never throw.

## Migration Notes

If you are updating an older plugin:

- move handled commands into `manifest.commands`
- move any automatic startup behavior into host-level `workflows`
- replace status registry usage with a plugin-level `summarize()` hook
- stop relying on implicit prefix-based routing such as assuming `my-plugin.*` commands will be dispatched automatically
- ensure any plugin that declares `manifest.commands` returns an `executeCommand()` implementation from `setup()`

## Next Steps

- Explore [Built-in Plugins](/reference/content/) for real-world examples
- See the [Controller Reference](/reference/controller/) for plugin contract details
- Learn about [Content Sources](/reference/content/sources/) and [Content Transforms](/reference/content/transforms/) for content-specific extension points
