Plugin SDK: the public extension contract
@horribledashboard/sdk (packages/sdk/) is the unified framework third-party programs
build against to integrate into the platform. A plugin contributes the same
things a built-in module does — commands, panels, dashboard widgets,
keybindings — using the exact same declaration types, and is installed from the
in-app marketplace.
This is the frontend contract. For server-side contributions (HTTP routes,
agent tools, /ws channels), see the
backend plugin SDK — a rich plugin can ship both halves.
The package is published to npm as
@horribledashboard/sdk (MIT). Inside
the monorepo its exports point at the TypeScript source; the published tarball
ships compiled ESM + .d.ts from dist/ via publishConfig overrides
(pnpm publish from packages/sdk — prepublishOnly runs the build).
Trust model (v1, stated plainly): plugins are trusted code, Obsidian/VS Code style. They run unsandboxed as ES modules in the app's realm with full DOM and SDK access. Marketplace curation is the safety layer; install only what you trust. Sandboxing is a possible later addition for untrusted sources, not part of this contract.
The contract
A plugin's entry module default-exports definePlugin({ setup }):
import { definePlugin } from '@horribledashboard/sdk';
export default definePlugin({
setup(host) {
return {
widgets: [{ id: 'my-plugin.thing', title: 'Thing', component: Thing }],
commands: [{ id: 'my-plugin.doIt', title: 'My Plugin: Do it', run: () => {} }],
settings: [{ key: 'my-plugin.size', title: 'Size', type: 'number', default: 5 }],
// panels?, keybindings? — same shapes built-in modules use
// panelGroups? — cluster related panels/widgets into a toggle-strip group
};
},
});
setup(host)is called once at boot; it may be async. The returned contributions are registered in the module registry as moduleplugin:<id>.- Namespacing is enforced: every contributed command/panel/widget id and
setting key must start with
<pluginId>.— the loader rejects the plugin otherwise. - The
hosthandle (PluginHost) is the only door back into the shell:host.api.get/post/put/del— the backend HTTP client (relative to/api).host.storage.get/set/remove— key-value storage scoped to the plugin, persisted server-side (/api/plugins/<id>/storage/<key>). Uninstall wipes it.host.settings.get/set/subscribe— values of the settings the plugin declared, namespace-guarded to its own keys. Distinct from storage: settings are user-configurable on the settings page; storage is the plugin's own bookkeeping. See ../modules/settings.md.host.hasCapability(cap)— platform capability checks (browser vs desktop).host.subscribeChannel(channel, handler)— the shared/wssocket.host.openPanel(panelId)/host.runCommand(commandId).
Declaration types (CommandDecl, PanelDecl, WidgetDecl, KeybindingDecl,
SettingDecl, SettingType, Capability, CollabDecl, PanelGroupDecl,
WsMessage) live in the SDK and are re-exported by @horrible/core, so plugins
and built-in modules share one contract. A KeybindingDecl can be focus-scoped: set scope to a pane
view id so the binding is active only while that pane is focused (shadowing plain
globals), or override: true on a global so it can't be shadowed — see
keybinding scopes.
A plugin's widgets are dockable panes, just like a built-in module's: they
open into the workspace (tabbed/split/floating), so a WidgetDecl takes an
optional defaultPlacement (left|center|right|bottom) hinting where it docks.
There is no separate widget board — see
windowing.md.
A pane can also opt into the distributed peer fabric declaratively via the
optional collab field (CollabDecl) on PanelDecl/WidgetDecl: it names the
shared room the pane syncs through, and the pane consumes it with the
useCollab host hook (live cross-node state + presence) rather than touching the
collab channel directly. See
Module: network.
Versioning
SDK_API_VERSION (currently 1) is bumped on breaking contract changes
only. A plugin package declares the version it was built against (sdkVersion
in its manifest); the loader skips plugins whose version doesn't match and
surfaces the mismatch in the marketplace panel. Additive optional fields (like
the settings contribution in SDK 0.2.0, WidgetDecl.defaultPlacement in
0.3.0, and the collab pane field in 0.4.0) do not bump it, so existing
plugins keep loading.
Package format
A plugin is a directory:
my-plugin/
horrible-plugin.json # package manifest (below)
dist/index.js # built ESM entry
src/ … # source (authoring)
horrible-plugin.json:
{
"id": "my-plugin",
"name": "My Plugin",
"version": "0.1.0",
"description": "…",
"author": "…",
"entry": "dist/index.js",
"sdkVersion": 1,
"requiredCapabilities": [],
"permissions": ["storage"]
}
id:^[a-z0-9][a-z0-9-]{0,63}$, and must equal the directory name.entry: relative path, no..segments.permissions: informational in v1 (shown in the marketplace UI); reserved for enforcement later (e.g. a fetch proxy permission).
Building a plugin (authoring guide)
Copy examples/plugins/hello-widget/ — the reference plugin (a persisted
counter widget + a command). The build is a Vite lib build via the SDK preset:
// vite.config.ts
import { defineConfig } from 'vite';
import { horriblePluginViteConfig } from '@horribledashboard/sdk/vite';
export default defineConfig(horriblePluginViteConfig({ entry: 'src/index.tsx' }));
The preset marks react, react/jsx-runtime, and @horribledashboard/sdk external
and rewrites them to host-served shim URLs. This is load-bearing:
- The shims (
apps/web/public/plugin-runtime/{react,jsx-runtime,sdk}.js) are static ES modules that re-export fromwindow.__HORRIBLE_RUNTIME__, which the host populates from its own bundled React before any plugin loads. - This guarantees one React instance across host and plugins. A plugin that bundles its own React will crash on hooks — the loader cannot detect this, so don't bypass the preset.
- The same URLs work in Vite dev (served from
public/), the production build, and the Tauri webview. The shims are always served by the page origin — under the desktop layout the plugin entry itself is fetched from the backend origin (see below), so the loader pins the shim specifiers to the page origin before evaluating the bundle.
pnpm build produces dist/index.js (a few kB — everything heavy is
external). Drop the directory into a catalog directory and it's installable.
Catalog and lifecycle
- The backend scans
HORRIBLE_PLUGIN_CATALOG(defaultexamples/plugins) for*/horrible-plugin.json— the catalog is self-indexing. Pointing this at a hosted registry later is a config change, not a redesign. - Install copies the package into
$HORRIBLE_DATA_DIR/plugins/<id>/package/(reinstall = update in place). Uninstall removes the whole plugin dir, including its storage. - At boot,
loadPlugins()(packages/core/src/plugins/loader.ts) fetches the installed list, then loads each enabled plugin's entry from/api/plugins/<id>/assets/<entry>(an absolute backend URL under the desktop layout): it fetches the source, rewrites the root-absolute/plugin-runtime/*shim imports to the page origin, and imports the result as a Blob module — a plainimport()would resolve the shims against the entry's origin and 404 cross-origin. Contributions are then registered. Failures are contained per plugin and listed in the marketplace panel. - The backend serves
.js/.mjsassets with a forcedtext/javascriptMIME — Windows' registry-backedmimetypescan claimtext/plain, which browsers reject for ES modules.
Known limitations (v1)
- Reload to apply: install/update/enable/disable/uninstall take effect on the next reload (the registry has no unregister; the dock holds live component refs). The marketplace shows a reload banner.
- No plugin HMR: plugin entries are imported via backend URLs that bypass Vite's transform pipeline.
- Single-file bundles only: entries are evaluated as Blob modules, which have no base URL — relative chunk imports inside a plugin bundle won't resolve. The SDK build preset produces a single file; don't enable code splitting in a plugin build.
- Stale layout references: a workspace layout that references a disabled plugin's pane (panel or widget) renders the shell's "Unknown pane" placeholder until the pane is closed.
- Frontend-only: plugins cannot ship backend Python. The host provides generic backend services (storage now; more later). A fetch proxy is deliberately excluded until a permissions design exists (SSRF surface).