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:
- No runtime imports. Only
import type(erased at compile time) is allowed. Bundle any real dependencies into the single output file. - Never bundle your own React. Use
ctx.react— two React copies break hooks. A handy alias isconst 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’sjsxFactory: "h"), then keepconst h = ctx.react.createElementin scope in each file. The output still routes element creation throughctx.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:
- Edit a file under
src/. - Run
pnpm build(ornpm run build). - Hit Reload on the plugin in Settings → Plugins — no app restart.
Next
- Plugin anatomy & lifecycle — the manifest, entry shapes, and the hot-unload invariant the types above encode.
- Main-process context and
Renderer context — the full
ctxreference.