Plugin development How-to guide

Writing plugins in TypeScript

Author a plugin in TypeScript with full type-checking, then compile to the CommonJS main and import-free ESM renderer that Muxie loads.

Muxie loads two plain JavaScript files at runtime — a CommonJS main.cjs and an import-free ESM renderer.js (see Plugin anatomy). Nothing stops you authoring them in TypeScript and compiling down to that shape. This guide sets up types, a tsconfig, and a small build so you get full editor type-checking on ctx, hooks, and slots.

Note: Muxie doesn’t yet publish a types package for plugin authors, so this guide includes a hand-written declaration file you drop into your project. A first-party types package is future work — until then, keep the declarations below in sync with the hook and slot catalogs.

Project layout

Author under src/, compile to the two entry files at the plugin root:

my-plugin/
  src/
    main.ts            # → compiles to main.cjs (CommonJS)
    renderer.ts        # → compiles to renderer.js (ESM, import-free)
  types/
    muxie-plugin.d.ts  # hand-written context types (below)
  muxie-plugin.json    # manifest (still points at main.cjs / renderer.js)
  package.json
  tsconfig.json
  build.mjs            # esbuild build

The manifest is unchanged — it references the compiled files:

{
  "id": "my-plugin",
  "name": "My Plugin",
  "version": "1.0.0",
  "main": "main.cjs",
  "renderer": "renderer.js"
}

The type declarations

Drop this into types/muxie-plugin.d.ts. It mirrors the real ctx interfaces and the documented hook and slot maps. Extend the SlotItemMap with the other slots from the Slot catalog as you use them.

// types/muxie-plugin.d.ts
import type * as React from "react";

export interface Disposable {
  dispose(): void;
}

/** Command-palette command (also the `commands` slot item). */
export interface Command {
  id: string;
  title: string;
  keywords?: string;
  run: () => void;
}

/** A plugin pane-tab. */
export interface PaneTab {
  surfaceType?: string;
  surfaceState?: Record<string, unknown>;
  title?: string;
}

/** A pane-surface renderer. */
export interface SurfaceDefinition {
  render: (tab: PaneTab) => React.ReactNode;
  title?: (tab: PaneTab) => string;
  icon?: React.ReactNode;
}

/** Main-process lifecycle hooks — see the Hook catalog. */
export interface MainHookMap {
  "app:ready": Record<string, never>;
  "window:created": { windowId: number };
  "session:spawned": {
    sessionId: string;
    spriteName?: string;
    daemonHost?: string;
    shell?: string;
    args?: string[];
  };
  "session:exited": { sessionId: string; exitCode: number };
  "connection:lost": { sessionId: string; transport: "sprite" | "daemon" };
  "connection:reconnected": { sessionId: string; transport: string; attempts: number };
  "connection:reconnect-failed": { sessionId: string; transport: string; attempts: number };
  "config:changed": { config: unknown };
  "gpu:restarted": { reason: string };
  "app:will-quit": Record<string, never>;
}

/** Renderer lifecycle hooks — see the Hook catalog. */
export interface RendererHookMap {
  "tab:activated": { tabId: string };
  "pane:focused": { paneId: string };
  "theme:changed": { themeId: string };
  "workspace:flyout-opened": { tabId: string };
  "agent:state-changed": {
    sessionId: string;
    phase: "working" | "blocked" | "idle";
  };
}

/** A subset of the UI slots — add the rest from the Slot catalog. */
export interface SlotItemMap {
  commands: Command;
  "sidebar.actions": {
    title: string;
    icon: React.ReactNode;
    onClick: () => void;
    testid?: string;
    badge?: number;
  };
  "sidebar.sections": { id: string; render: () => React.ReactNode };
  "settings.sections": { title: string; render: () => React.ReactNode };
  "dock.panels": {
    id: string;
    title: string;
    icon?: React.ReactNode;
    render: () => React.ReactNode;
    useBadgeCount?: () => number;
    headerActions?: React.ReactNode;
  };
  "pane.banners": { id: string; render: (sessionId: string) => React.ReactNode };
  "terminal.contextMenu": { label: string; run: () => void };
}
export type SlotName = keyof SlotItemMap;

export interface PluginMainContext {
  id: string;
  log(message: string): void;
  pluginDir: string;
  hooks: {
    on<K extends keyof MainHookMap>(
      name: K,
      fn: (payload: MainHookMap[K]) => void
    ): Disposable;
  };
  ipc: {
    handle(
      name: string,
      fn: (event: unknown, ...args: unknown[]) => unknown
    ): Disposable;
  };
  config: { get(): Readonly<Record<string, unknown>> };
  terminal: {
    onSessionOutput(cb: (sessionId: string, data: string) => void): Disposable;
    onSessionInput(cb: (sessionId: string, data: string) => void): Disposable;
  };
  storage: { get(): unknown; set(value: unknown): void };
  track(d: Disposable): void;
}

export interface PluginRendererContext {
  id: string;
  log(message: string): void;
  hooks: {
    on<K extends keyof RendererHookMap>(
      name: K,
      fn: (payload: RendererHookMap[K]) => void
    ): Disposable;
  };
  slots: {
    contribute<K extends SlotName>(slot: K, item: SlotItemMap[K]): Disposable;
  };
  commands: { register(command: Command): Disposable };
  surfaces: { register(surfaceType: string, def: SurfaceDefinition): Disposable };
  workspace: {
    openSurface(
      surfaceType: string,
      opts?: { state?: Record<string, unknown>; title?: string }
    ): void;
    onTabClosing(
      fn: (info: {
        tab: PaneTab;
        running: string | null;
        close: () => void;
      }) => boolean
    ): Disposable;
  };
  invoke(name: string, payload?: unknown): Promise<unknown>;
  react: typeof React;
  track(d: Disposable): void;
}

/** Both entries export `activate`; the optional return runs on deactivate. */
export type Activate<Ctx> = (ctx: Ctx) => void | { deactivate(): void };

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "skipLibCheck": true,
    "noEmit": true
  },
  "include": ["src", "types"]
}

noEmit is on because we let esbuild produce the output files; tsc --noEmit (or your editor) does the type-checking. You’ll need typescript and @types/react as dev dependencies.

The main entry (src/main.ts)

Compiles to a CommonJS main.cjs. Node APIs and bundled dependencies are fine here — only the entry file is hot-reloaded, so bundle to a single file.

// src/main.ts
import type { Activate, PluginMainContext } from "../types/muxie-plugin";

interface State {
  pings: number;
}

export const activate: Activate<PluginMainContext> = (ctx) => {
  const state = (ctx.storage.get() as State | undefined) ?? { pings: 0 };

  // The renderer half calls this via ctx.invoke("ping").
  ctx.ipc.handle("ping", () => {
    state.pings += 1;
    ctx.storage.set(state);
    return { pong: true, pings: state.pings };
  });

  ctx.hooks.on("session:spawned", ({ sessionId }) => {
    ctx.log(`session spawned: ${sessionId}`);
  });

  return { deactivate: () => ctx.log("main deactivated") };
};

The renderer entry (src/renderer.ts)

Compiles to an import-free ESM renderer.js. Two rules survive compilation:

  1. No runtime imports. Only import type (erased at compile time) is allowed. Bundle any real dependencies into the single output file.
  2. Never bundle your own React. Use ctx.react — two React copies break hooks. A handy alias is const h = ctx.react.createElement.
// src/renderer.ts
import type { Activate, PluginRendererContext } from "../types/muxie-plugin";

export const activate: Activate<PluginRendererContext> = (ctx) => {
  const h = ctx.react.createElement;

  ctx.commands.register({
    id: "my-plugin.greet",
    title: "My Plugin: Greet",
    keywords: "example demo",
    run: () => {
      void ctx.invoke("ping").then((r) => {
        const { pings } = r as { pings: number };
        ctx.log(`pings so far: ${pings}`);
      });
    },
  });

  ctx.surfaces.register("my-plugin.demo", {
    title: () => "My Plugin",
    render: (tab) =>
      h(
        "div",
        { style: { padding: 16, fontSize: 13 } },
        h("h2", { style: { margin: "0 0 8px" } }, "👋 Hello from a typed plugin"),
        h(
          "p",
          { style: { margin: 0, opacity: 0.7 } },
          `Opened at: ${String(tab.surfaceState?.openedAt ?? "unknown")}`
        )
      ),
  });

  ctx.slots.contribute("sidebar.actions", {
    title: "My Plugin",
    icon: h("span", null, "👋"),
    onClick: () =>
      ctx.workspace.openSurface("my-plugin.demo", {
        state: { openedAt: new Date().toISOString() },
        title: "My Plugin",
      }),
  });

  return { deactivate: () => ctx.log("renderer deactivated") };
};

Tip: Prefer JSX? Set "jsx": "react" and "jsxFactory": "h" in your tsconfig (and esbuild’s jsxFactory: "h"), then keep const h = ctx.react.createElement in scope in each file. The output still routes element creation through ctx.react.

The build (build.mjs)

A two-target esbuild build emits both files:

// build.mjs
import { build } from "esbuild";

// main → CommonJS single file
await build({
  entryPoints: ["src/main.ts"],
  outfile: "main.cjs",
  bundle: true,
  platform: "node",
  format: "cjs",
  target: "node20",
});

// renderer → import-free ESM; React comes from ctx.react, never bundled
await build({
  entryPoints: ["src/renderer.ts"],
  outfile: "renderer.js",
  bundle: true,
  format: "esm",
  target: "es2022",
  external: ["react", "react-dom"],
});

Wire it up in package.json:

{
  "scripts": {
    "build": "tsc --noEmit && node build.mjs"
  },
  "devDependencies": {
    "esbuild": "^0.24.0",
    "typescript": "^5.6.0",
    "@types/react": "^18.3.0"
  }
}

bundle: true inlines your own dependencies, so the emitted renderer.js has no unresolved import statements — which is exactly what the host requires.

Develop and reload

Point Muxie at your plugin folder once (Settings → Plugins → Install from folder…). Then your loop is:

  1. Edit a file under src/.
  2. Run pnpm build (or npm run build).
  3. Hit Reload on the plugin in Settings → Plugins — no app restart.

Next