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).
What you’ll build
Section titled “What you’ll build”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.
Before you start
Section titled “Before you start”- The TeamHUD monorepo checked out and running —
pnpm install, thenpnpm start:lite(standard Node 20+ / pnpm setup). - Skim the Widgets concept.
A widget plugin is two halves
Section titled “A widget plugin is two halves”- A main-process plugin (
extends BasePlugin) that registers where the renderer bundle lives. - A renderer Lit component (
extends OverlayHubWidget) that is the actual UI.
1. Create the package
Section titled “1. Create the package”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.
2. Write the plugin (main process)
Section titled “2. Write the plugin (main process)”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:
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; }3. Write the component (renderer)
Section titled “3. Write the component (renderer)”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/.
4. Wire it into the running app
Section titled “4. Wire it into the running app”Three edits in apps/desktop-lite/:
package.json— add the workspace dependency:"@teamhud/widget-hello": "workspace:*",src/plugin-entries.ts— import the class and register it:import { WidgetHelloPlugin } from '@teamhud/widget-hello';// ...inside getEnabledPluginEntries(), add:toPluginEntry(WidgetHelloPlugin),src/preload.ts— keepE2E_DESKTOP_LITE_PLUGIN_NAMESin sync (add your slug).
5. Build and run
Section titled “5. Build and run”pnpm --filter @teamhud/widget-hello build # tsc → dist/pnpm --filter @teamhud/desktop-lite build:component-bundles # → dist-plugins/widget-hello-widget.jspnpm start:liteThe 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.
Verify as you go
Section titled “Verify as you go”Before running the app, the scoped checks catch most mistakes:
pnpm --filter @teamhud/widget-hello type-checkpnpm check:widget-contract # fails on hand-rolled drag/placement, off-palette or font literalsNext steps
Section titled “Next steps”- Widget API reference — the base-class overrides and design tokens.
- Widgets concept — regions, auto-collapse, and what you get for free.