Plugin Development
UI Plugins and the api Object
The in-app ui runtime kind, the complete api surface, slots, bundling, and hybrid plugins.
The ui runtime kind is a second kind of plugin alongside process. A UI plugin is a single JavaScript module the host loads into its own interface at runtime. It can contribute real interface: a title bar button, a floating panel, a column docked into an existing pane, command palette rows, bindable shortcuts, and its own popout OS windows. Installing one brings the whole feature. Uninstalling removes every trace.
Both kinds share one manifest format, one marketplace, one signing chain, and one install and consent flow. They differ only in where the code runs.
Info
New here? Start with the quickstart and the manifest reference. This page assumes you know the basics and focuses on the in-app runtime.
process vs ui
process | ui | |
|---|---|---|
| Runs as | A separate executable beside the app | A JavaScript module inside the app interface |
| Best at | Background behavior: long-running loops, own networking, work that must not depend on the interface being awake | Interface features: panels, buttons, windows, anything the user sees and touches |
| Can ship UI | Through a UI module (see Hybrid below) | Yes (contributes its own components) |
| Talks to the host via | JSON-RPC over stdio | A JavaScript api object passed at load |
| Per-platform builds | One binary per OS | One artifact everywhere the app runs |
Choosing: if the feature is something the app does in the background, make it a process plugin. If it is something the user sees and operates, make it a ui plugin. If it is both (a background worker with its own native controls), make it a hybrid: a process plugin that also ships a UI module (see Hybrid plugins below).
Manifest
A UI plugin uses the identical schema as any other plugin, with the runtime block selecting the kind:
id = "app.streamnook.lists"
name = "Lists"
version = "1.0.0"
author = "StreamNook"
tier = "A"
description = "User-curated reference lists: usernames, commands, titles."
host_min = "7.0.0"
[runtime]
kind = "ui"
entry = "dist/main.js"For a pure kind = "ui" plugin:
entryis the path of the bundled JavaScript module, relative to the plugin directory.argsandtransportare not used and should be omitted.[capabilities]lists (events,host_methods,credentials,network,ui) are wire-protocol concepts forprocessplugins and stay empty. The module contract below is the complete surface a UI plugin gets.[contributes]is likewise unused: UI plugin contributions are registered live through theapiobject, not declared in the manifest.
A hybrid plugin (a process sidecar that also ships a UI module) keeps kind = "process" and adds ui_entry. Its [capabilities] and [contributes] blocks apply to the sidecar as normal. See Hybrid plugins below.
Note
See the manifest reference for the full schema of every field.
Module contract
The entry file is a bundled ES module exporting:
// Called once when the plugin loads in the main window. Register
// contributions here. May return a cleanup function.
export function activate(api) { /* ... */ }
// Optional. Called before the plugin is unloaded (disable, uninstall,
// update). The host also tears down every registration automatically,
// so this is for the plugin's own listeners and timers.
export function deactivate() { /* ... */ }
// Optional. Called in a popout OS window opened via api.windows.open.
// Returns the React component to render as that window's content
// (the host provides the window chrome, theme, and titlebar).
export function windowSurface(surfaceId, api) { return MyComponent; }Loading: the host reads the bundle from the installed plugin directory and imports it when the plugin is enabled, at app start and on every enable. Disabling unloads it: deactivate runs, every registration is removed, and the module instance is discarded. Re-enabling evaluates a fresh instance.
Windows that host plugin contributions (the main window, the multi-chat popout) each load enabled UI plugins into their own context. activate runs per window. Registrations only surface where a consumer for them exists.
The api object
Everything a UI plugin can reach. Frozen, per plugin, passed to activate and windowSurface.
api.libs
The host's own copies of shared libraries, so plugin bundles stay small and run on the same React tree:
api.libs.react(also wired through the build shims, below)api.libs.reactJsxRuntimeapi.libs.framerMotion
Everything else a plugin needs (icons, state libraries) it bundles itself.
api.components
Native components plugins should reuse instead of rebuilding:
api.components.Tooltip(props:content,side,delay,disabled,children)
api.ui.registerTitleBarButton
api.ui.registerTitleBarButton({ id, tooltip, Icon, onClick, useIsActive? })Adds a button to the title bar's action cluster, rendered in native style. Icon is a component receiving { size }. useIsActive is an optional React hook returning whether the button shows the accent tint (read it from the plugin's own state).
A title-bar button also takes an optional useIsVisible hook alongside useIsActive. Return false to hide the button. This is how a plugin lets the user decide whether its button rides in the title bar: keep the choice in one of the plugin's own settings and read it from useIsVisible, so toggling the setting adds or removes the button live. The Lists plugin does exactly this (a "Title bar button" switch in its settings panel. The panel author picks the icon, the user picks whether it shows).
Each button is its own component, so useIsActive and useIsVisible are real React hooks and may read any store.
api.ui.registerOverlay
api.ui.registerOverlay({ id, Component })Mounts Component at the app root, above the main layout. The component owns its visibility (render null when closed), positioning, and animation. This is how a plugin ships a floating panel.
api.ui.registerSlot
api.ui.registerSlot(slotId, { id, label, Icon, Component })Fills a named slot a host feature exposes. The owning feature decides how slot contributions render. Slot ids are namespaced and host-defined, like hook ids. See the slot catalog below.
api.commands.registerKeybinding
api.commands.registerKeybinding({ id, label, description?, category?, defaultBindings, keywords?, run })Adds a bindable command: it dispatches globally, appears in the Keybindings settings, and user rebinds persist under its id. A plugin replacing a former core feature may reuse the historical command id so existing user rebinds keep working.
api.commands.registerPaletteItems
api.commands.registerPaletteItems(provider)provider is called on each palette open and returns rows:
{ id, section?, title, subtitle?, keywords?, icon?, run }api.settings.registerPanel
api.settings.registerPanel(Component)Registers the plugin's own settings component. It renders on the plugin's card in the plugins page, under a gear that appears while the plugin is enabled. This is the in-process equivalent of a process plugin's host-rendered panel, except the plugin ships the actual UI: build it from api.components and your own controls so it reads as part of the app. Persist values yourself (under a streamnook.<feature>. localStorage key) and read them reactively in the contributions that depend on them (a title-bar button's useIsVisible, an overlay's contents, and so on), so a settings change takes effect live.
api.windows.open
api.windows.open({ surface, title, width?, height?, minWidth?, minHeight? })Opens (or focuses) a popout OS window for this plugin. The host renders the standard frameless titlebar (icon, title, keep-on-top pin, minimize, close), applies the user's theme, and mounts the component returned by the module's windowSurface(surface, api). One window per (plugin, surface). Reopening focuses it.
api.events
api.events.emit(name, payload)
api.events.listen(name, handler) // -> Promise<unlisten>App-wide events that cross window boundaries, for state sync between the main window and popouts. Names should be prefixed with the plugin's id unless keeping a historical name for data continuity. Listeners are cleaned up automatically at unload.
api.chat
api.chat.useHasTarget() // React hook
api.chat.insertText(text) // -> booleanapi.chat.useHasTarget()is a React hook: true when a chat compose box exists in this window (a stream is being watched or the multi-stream grid is up).api.chat.insertText(text)inserts into the chat compose box at the caret. Returnsfalsewhen none is mounted.
api.storage
UI plugins read and write localStorage directly (it is shared across the app's windows, which is what makes popout sync work). Keys must be prefixed: streamnook.<feature>. for a feature the plugin owns. There is no separate storage API.
api.log
api.log.debug(...)
api.log.warn(...)
api.log.error(...)api.log.debug / warn / error, prefixed with the plugin id in the app log.
Tip
Need the host method and hook calls that hybrid plugins lean on? See the host methods reference and the hooks reference.
Slot catalog
Slots host features currently expose to UI plugins:
modlogs.dock: a column inside the Moderator Logs pane, side by side with the log columns. The pane shows one toggle button per contribution (using itsIconandlabel) and persists each toggle atstreamnook.modlogs.dock.<id>.
Building a UI plugin
Bundle to a single ES module with the shared libraries aliased to the host's copies.
Configure esbuild
Bundle to one ESM file and alias the shared libraries to local shims.
import { build } from 'esbuild';
build({
entryPoints: ['src/index.tsx'],
bundle: true,
format: 'esm',
outfile: 'dist/main.js',
jsx: 'automatic',
alias: {
'react': './shims/react.cjs',
'react/jsx-runtime': './shims/jsx-runtime.cjs',
'framer-motion': './shims/framer-motion.cjs',
},
});Add the shims
Each shim re-exports the host copy, e.g. shims/react.cjs:
module.exports = globalThis.__STREAMNOOK_HOST_LIBS__.react;Warning
Sharing React this way is required, not an optimization: contributed components render inside the host's React tree, and hooks only work when both sides run the same React instance. Everything else (icons, a state library) is bundled into the plugin normally.
Hybrid plugins (process + ui)
A process plugin can also ship a UI module by adding ui_entry to its [runtime] block:
[runtime]
kind = "process"
entry = "worker.exe" # the sidecar
ui_entry = "dist/main.js" # the in-app UI module
transport = "stdio"When the plugin is enabled, the host runs both: it spawns the sidecar and the frontend loads the UI module, exactly as for a standalone ui plugin. When it is disabled, the sidecar shuts down and the UI unloads. One plugin, one install, one toggle.
The two halves are separate programs, so they talk through the existing hook system, not directly. The UI module invokes the sidecar's actions and reads its status with the same calls the host's own UI uses:
// Drive the sidecar.
invoke('plugins_invoke_action', { action, args })
// Check the sidecar is backing a feature.
invoke('plugins_provides', { feature })
// Receive the sidecar's status pushes (filter by the event's plugin_id).
listen('plugin://status', ...)This is how a background worker contributes native controls: the sidecar does the work and owns the data. The UI module contributes the buttons, the status display, and the settings panel, and feels like part of the app while the plugin is on and disappears when it is off. The UI module shares the host's React and uses the whole api above (title-bar buttons, overlays, slots, palette rows, keybindings, windows, and a settings panel) on top of the action and status calls.
Trust model
A UI plugin runs inside the app's interface with the same reach as the app's own interface code. That is the same trust bar as a process plugin, which runs native code on the machine. What protects users is unchanged across both kinds: curation into a signed index, artifact signatures and hashes verified before anything runs, the install consent dialog, and one-click disable and uninstall.
Same protections regardless of kind: signed index, verified signatures and hashes, install consent, one-click disable and uninstall.
Versioning
The module contract and api surface above are version 1 of the UI runtime. Additive changes (new api members, new slots, new optional fields) do not bump it. Removing or changing existing members does. Plugins should feature-detect optional members:
if (api.ui.registerSlot) {
// safe to use the slot API
}