Skip to content

Build your first plugin

  • 2 halves: a main-process plugin and a renderer Lit component
  • 3 edits in apps/desktop-lite/ to wire it in
  • 0 drag, snap or auto-collapse code to write

In TeamHUD, widgets are registered by plugins. A plugin is the full extension unit (BasePlugin): it runs in-app with full trust and can integrate with in-app data and services — a widget is just one of the things it provides. This tutorial builds the smallest plugin that provides a widget. The finished result lives at plugins/widget-hello/ — copy it as your starting point, or follow along to understand each piece. widget-hello is a reference package only: it is not wired into the app (plugin-entries.ts does not list it), so §4 shows the three edits that make it load.

If you just want a standalone UI with no in-app data, the custom widget path is faster (no build step, no plugin).

A Hello hub that drags, snaps, hover-reveals, and auto-collapses — none of which you write. You implement a few small overrides; the OverlayHubWidget base class gives you the rest.

  • The TeamHUD monorepo checked out and running — pnpm install, then pnpm start (standard Node 24 / pnpm setup; the exact version is in .nvmrc).
  • Skim the Widgets concept.

A plugin that ships a widget is two halves

Section titled “A plugin that ships a widget is two halves”

Main-process plugin

extends BasePlugin. Registers where the renderer bundle lives.

Renderer Lit component

extends OverlayHubWidget. The actual UI.

Make plugins/widget-hello/ with the hand-written files (src/index.ts, src/WidgetHelloPlugin.ts, src/components/index.ts, src/components/widget-hello-widget.ts) plus package.json and tsconfig.json. The package.json carries the discovery manifest:

// plugins/widget-hello/package.json (excerpt)
{
"name": "@teamhud/widget-hello",
"type": "module",
"scripts": { "build": "tsc", "type-check": "tsc --noEmit" },
"dependencies": {
"@mythic/logging": "*",
"@mythic/plugins": "*",
"@teamhud/component-bundle-registry": "workspace:*",
"@teamhud/pipeline-primitives": "workspace:*",
"@teamhud/renderer": "workspace:*",
"@teamhud/ui-core": "workspace:*",
"@teamhud/widget-mixins": "workspace:*",
"lit": "^3.0.0"
},
"teamhud": {
"plugins": [
{
"slug": "widget-hello",
"mainEntry": "./src/WidgetHelloPlugin.ts",
"displayName": "Hello",
"tagline": "The minimal plugin that ships a widget.",
"category": "developer"
}
]
}
}

The teamhud.plugins[] block is how the build tooling discovers your plugin. It does not add the plugin to the running app — that’s a separate manual step (§4). Copy tsconfig.json from any plugins/widget-* — it must emit to dist/, never next to sources.

The plugin’s only job is startup(), which tells the overlay where to find the bundle:

// plugins/widget-hello/src/WidgetHelloPlugin.ts (the important part)
export class WidgetHelloPlugin extends BasePlugin {
info(): IPluginInfo {
return {
// ...name/version/etc...
tags: { canDisable: true, isWidget: true },
resources: [{ name: 'widget-hello', kind: 'widget', displayName: 'Hello Widget', canDisable: true }],
};
}
override async startup(): Promise<void> {
ComponentBundleRegistry.getInstance().registerBundle({
bundleName: 'widget-hello-widget', // must match the built bundle key
targetWindows: ['overlay'],
federationModule: '/plugins/widget-hello/components',
pluginSlug: 'widget-hello',
resourceName: 'widget-hello', // must match resources[].name above
});
}
}

Two names must line up: bundleName must equal the bundle key the build derives from your folder (a widget- plugin with src/components/index.ts → widget-hello-widget), and resourceName must match a resources[].name so the enable/disable toggle works.

The bundle entry mounts the element:

plugins/widget-hello/src/components/index.ts
import { mountCapableWidget } from '@teamhud/widget-mixins';
import './widget-hello-widget.js';
let active: HTMLElement | null = null;
export function initialize(context) { dispose(); active = mountCapableWidget('widget-hello-widget', context); }
export function dispose() { active?.remove(); active = null; }

Extend OverlayHubWidget and implement only what’s yours: the region id, the hub button, the hover-visual hook (overlayRefreshHoveredVisual) and the panel body. Drag, placement, hover-reveal, auto-collapse, and region-sync are inherited.

// plugins/widget-hello/src/components/widget-hello-widget.ts (the overrides)
@customElement('widget-hello-widget')
export class WidgetHelloWidget extends OverlayHubWidget {
@state() private accessor _count = 0;
protected overlayRegionOwnerId(): string { return 'widget-hello'; }
protected overlayHubRegistryEntry() {
return { id: 'widget-hello', label: 'Hello', icon: HELLO_ICON /* inline SVG, currentColor */ };
}
protected overlayRefreshHoveredVisual(hovered: boolean): void { this._hovered = hovered; }
// render() mounts #hub-btn-mount and, when open, a <th-hub-panel> whose body is yours.
}

Everything visual reads from --th-* tokens — never hardcode colors, fonts, or motion. Don’t guess token names: use only names from the catalog (for example --th-text-sm, --th-radius-md, --th-bg-surface). The full catalog and rules are in Theming & styling. The complete render() + <th-hub-panel> wiring is in plugins/widget-hello/.

Three edits in apps/desktop-lite/:

  1. package.json — add the workspace dependency:
    "@teamhud/widget-hello": "workspace:*",
  2. src/plugin-entries.ts — import the class and register it:
    import { WidgetHelloPlugin } from '@teamhud/widget-hello';
    // ...inside getEnabledPluginEntries(), add:
    toPluginEntry(WidgetHelloPlugin),
  3. src/preload.ts — keep E2E_DESKTOP_LITE_PLUGIN_NAMES in sync — it lists plugin class names, so add "WidgetHelloPlugin" (not the slug).

plugin-entries-discoverable.test.ts fails any booted entry whose package.json lacks a teamhud.plugins block, which is why §1 includes it.

Terminal window
pnpm --filter @teamhud/widget-hello build # tsc → dist/
pnpm --filter @teamhud/desktop-lite build:component-bundles # → dist-plugins/widget-hello-widget.js
pnpm start # from the repo root; runs apps/desktop-lite's dev script

The Hello hub appears on the overlay. Click it → the panel opens with a working counter; drag it → it snaps to the edge; move the pointer away → it auto-collapses.

Before running the app, the scoped checks catch most mistakes:

Terminal window
pnpm --filter @teamhud/widget-hello type-check
pnpm check:widget-contract # fails on hand-rolled drag/placement, off-palette or font literals