Skip to content

Build your first plugin

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.

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 three things; the OverlayHubWidget base class gives you the rest.

  • The TeamHUD monorepo checked out and running — pnpm install, then pnpm start:lite (standard Node 20+ / pnpm setup).
  • Skim the Widgets concept.
  1. A main-process plugin (extends BasePlugin) that registers where the renderer bundle lives.
  2. A renderer Lit component (extends OverlayHubWidget) that is the actual UI.

Make plugins/widget-hello/ with four hand-written files 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 widget plugin.",
"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, 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 three 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 */ };
}
// 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 (e.g. --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 (add your slug).
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:lite

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