Plugin API
Hover's plugin API lets third-party packages contribute modes that show up in the widget's mode picker — each mode can register an extra MCP server, supply its own Chrome launch flags, append paragraphs to the agent's system prompt, and broadcast custom events to the widget.
@hover-dev/security is the first plugin built on this API. The shape below is everything you need to write your own.
Importing
import { defineHoverPlugin, type HoverPluginManifest } from '@hover-dev/core/plugin-api';
Minimal plugin
// packages/my-plugin/src/index.ts
import { defineHoverPlugin } from '@hover-dev/core/plugin-api';
export default defineHoverPlugin<{ verbose?: boolean }>((opts) => ({
apiVersion: 1,
name: '@example/hover-perf',
mode: {
id: 'perf',
label: 'Performance probing',
description: 'Capture Core Web Vitals and flag layout shifts.',
},
systemPromptAdditions: [
{
text: 'Performance mode is active. Use mcp__playwright__browser_evaluate to capture LCP / CLS and report anomalies as Findings.',
},
],
hooks: {
async 'hover:mode:activate'(ctx) {
// Boot any sidecar (a profiler, a metrics server) here.
if (opts?.verbose) ctx.broadcast({ type: 'perf:started' });
},
async 'hover:mode:deactivate'() {
// Tear it down.
},
},
}));
User wires it into their bundler config:
import { hover } from 'vite-plugin-hover';
import perfMode from '@example/hover-perf';
export default defineConfig({
plugins: [hover({}, perfMode({ verbose: true }))],
});
Manifest
interface HoverPluginManifest {
apiVersion: 1; // literal, bumped on breaking changes
name: string; // unique, use the npm package name
mode?: HoverPluginMode; // contribute a widget mode
mcpServers?: HoverPluginMcpServer[]; // extra MCPs exposed to the agent
chromeFlags?: HoverPluginChromeFlags; // launch overrides
systemPromptAdditions?: HoverPluginSystemPromptAddition[];
widgetEventTypes?: string[]; // namespaces of events you'll broadcast
widgetEntry?: string; // absolute path to plugin widget JS module (v0.9+)
saveHandlers?: HoverPluginSaveHandler[];
hooks?: HoverHooks;
}
mode
interface HoverPluginMode {
id: string; // globally unique, lowercase kebab
label: string; // shown in the picker
description?: string; // shown in picker rows + tooltip
conflictsWith?: string[]; // other mode ids this can't coexist with
}
When set, the widget shows a mode bar above the panel header. Selecting this mode fires the hover:mode:activate hook below.
mcpServers
interface HoverPluginMcpServer {
id: string; // namespaced, e.g. '@you/foo:bar'
command: string; // absolute path or PATH-resolvable bin
args?: string[];
env?: Record<string, string>; // merged with runtime overrides
activeInModes?: string[]; // default: this plugin's own mode; '*' = always
}
Each entry becomes a key under the agent's MCP config when the matching mode is active. Claude exposes tools as mcp__<sanitised id>__<tool> (non-alphanumerics → underscore).
Runtime env that doesn't exist at manifest-construction time (a sidecar port, an auth token) goes through ctx.setMcpServerEnv(id, env) inside the activate hook — see hooks below.
chromeFlags
interface HoverPluginChromeFlags {
args?: string[]; // appended to Chrome argv
userDataDir?: string; // recommended when proxy is set
cdpPort?: number; // recommended for the same reason
proxy?: { port: number; spki: string }; // wired automatically by setChromeProxy()
activeInModes?: string[]; // default: this plugin's own mode
}
proxy is the load-bearing field for MITM-style plugins. When set, Hover launches Chrome with --proxy-server=127.0.0.1:<port> and --ignore-certificate-errors-spki-list=<spki>, so the proxy's CA validates without touching the OS trust store.
systemPromptAdditions
interface HoverPluginSystemPromptAddition {
text: string; // paragraph appended to the agent's prompt
activeInModes?: string[]; // default: this plugin's own mode; '*' = always
}
Concatenated onto the agent's system prompt for every command issued while a matching mode is active. Keep paragraphs short — every plugin adds prompt tokens. The active-mode prompt addition is currently uncapped, but a per-plugin token budget will land in a future release.
widgetEventTypes
widgetEventTypes?: string[]; // e.g. ['security:flow:added', 'security:flow:updated']
Documents the event types this plugin broadcasts. Today this is informational — the widget side accepts unknown event types but knows nothing about them. Future iterations will use this for tree-shaking the widget bundle.
widgetEntry (v0.9+)
widgetEntry?: string; // absolute path to a JS module
Resolves to an absolute path on disk pointing at a JS module that runs inside the widget's Shadow DOM. The host reads this file at bundle-assembly time, inlines it as a <script type="module"> after the widget core, and the module looks up window.__HOVER_WIDGET__ (set by packages/widget-bootstrap/src/widget/host.js) to contribute its UI.
Plugin authors typically resolve the absolute path inside the server-side entry, e.g.:
import { fileURLToPath } from 'node:url';
const widgetEntry = fileURLToPath(new URL('./widget.js', import.meta.url));
If absent, the plugin contributes no widget code (server-side-only plugin).
saveHandlers (v0.12+)
saveHandlers?: HoverPluginSaveHandler[];
Registers plugin-owned Save-dropdown entries. The widget broadcasts a type-keyed WS message when the user picks an entry; the matching server-side handler writes a file under the user's project and returns its path. Each HoverPluginSaveHandler has:
type: string— WS message type the widget will send. Convention:save:<plugin>:<kind>(e.g.save:security:spec).label: string— human-readable label shown in the UI (also used in confirmation toasts).description?: string— optional longer description for the dropdown row.activeInModes?: string[]— modes in which this entry is offered. Defaults to the plugin's own mode.handle({ devRoot, payload })→Promise<{ path, slug }>— async writer. Receives the user's project root and the widget-supplied payload (form field values); resolves with the absolutepathof the written file and theslugused to name it.
Widget host API (v0.9+)
Plugin widget modules see one global: window.__HOVER_WIDGET__. Its surface is:
interface WidgetHost {
apiVersion: 1;
registerPlugin(spec: WidgetPluginSpec): void;
getState(): Record<string, unknown>; // union of every plugin's namespaced state
setState(patch: Record<string, unknown>): void; // merged into the active plugin's slot
openOverlay(overlayId: string): void; // namespaced id, e.g. '@hover-dev/security:network'
closeOverlay(overlayId: string): void;
send(msg: object): void; // push a message to the service over WS
}
getState() returns the full union ({ [pluginName]: { ... } }); plugins are expected to read only their own entry. setState(patch) merges into the namespace of whichever plugin owns the currently-active mode — there's no need to repeat your name in every call. Calling setState while no plugin mode is active is a silent no-op.
WidgetPluginSpec
interface WidgetPluginSpec {
apiVersion: 1;
name: string; // matches the server-side manifest name
modeId: string; // matches the server-side mode.id
css?: string; // auto-namespaced — every selector becomes
// `[data-plugin-active="<name>"] <selector>`
domMutations?: { // applied on activate, reverted on deactivate
hide?: string[]; // set .hidden = true on each match
addClass?: Record<string, string>; // { selector: className }
};
toolbarButtons?: Array<{
id: string;
tooltip?: string;
icon?: string; // inline-SVG string or unicode
onClick?: (api: WidgetHost) => void;
badge?: (api: WidgetHost) => string | number | null;
}>;
overlays?: Array<{
id: string; // namespace it — '@you/plugin:panel'
title?: string;
actions?: Array<{
icon?: string;
tooltip?: string;
onClick?: (api: WidgetHost) => void;
}>;
render?: (container: HTMLElement, state: Record<string, unknown>) => void;
}>;
saveEntries?: SaveEntrySpec[];
onMessage?: Record<string, (payload: unknown, api: WidgetHost) => void>;
onActivate?: (api: WidgetHost) => void;
onDeactivate?: (api: WidgetHost) => void;
}
saveEntries (widget, v0.12+)
saveEntries?: SaveEntrySpec[];
Adds plugin-owned entries to the widget's Save dropdown. Each SaveEntrySpec (see packages/widget-bootstrap/src/widget/host.js):
type: string— must match a server-sideHoverPluginSaveHandler.type(e.g.save:security:spec).label: string— dropdown row label.sub?: string— optional sub-label / hint shown beneath the label.icon?: string— optional inline-SVG string or unicode glyph.title?: string— heading shown in the save dialog. Defaults tolabel.fields?: FieldSpec[]— input fields rendered in the dialog. Defaults to a single name field. Each field:{ id, label, placeholder?, required? }.confirmLabel?: string— button label. Defaults to"Save".successMsgTemplate?: string— toast template. Defaults to'✓ saved "{name}" → {path}';{name}and{path}are substituted from the field values and the server-side response.
domMutations is for plugin-owned DOM
hide / addClass look like they could target any element — but they're intended for the plugin's own contributions (toolbar buttons, overlay bodies, etc.). The default-mode widget core (Record, Fix, Send, footer, overlays, the mode bar itself) is not a target. Core owns its own visibility — it listens for modes payload changes and applies applyDefaultModeVisibility to its own widgets internally.
The host doesn't enforce this — pointing hide at .record-btn technically works — but it produces a two-sided coupling where the plugin tracks core selector names and core could refactor at any time. The @hover-dev/security widget module explicitly declares "no domMutations targeting core widget elements" in its source code; new plugins should follow the same discipline.
Single-mode exclusivity
At most one plugin's contributions are visible at any moment. Server-side currentModeId: string | null is the source of truth; the widget host's applyMode(newModeId) deactivates the prior owner (revert DOM mutations, remove overlays, remove toolbar buttons, remove <style>, call onDeactivate) before activating the new one (inject namespaced CSS, append toolbar buttons + overlays, apply domMutations, call onActivate). When newModeId === null, the widget looks identical to a build with no plugins — that's the symmetric "default mode" state.
Symmetric mode ownership
Default mode (Record / Fix / Send / etc.) and plugin modes share a symmetric protocol: each side owns its own widgets and listens for modes payload changes to show/hide them itself. Plugins never need to know default mode's selectors; default mode never knows about any specific plugin. Adding a new plugin no longer requires listing "what core buttons should I hide."
Failure mode
Every plugin-supplied callback runs inside a try/catch inside the host. A plugin crashing in registerPlugin / onMessage / overlay.render / onActivate / onDeactivate produces a structured [hover/plugin "<name>"] <where> failed: <msg> console error, but never blocks the WS pump or other plugins.
Reference: packages/security/src/widget.js
The 224-line packages/security/src/widget.js is the canonical example. It registers @hover-dev/security against modeId: 'security', contributes ~120 lines of namespaced CSS, one toolbar button (network), one overlay (@hover-dev/security:network), one saveEntries entry (Security spec in the Save dropdown, triggers writing __vibe_tests__/<slug>.security.spec.ts), and two WS message handlers (security:flow:added / security:flow:updated). onDeactivate drops the captured flow list so re-entering security mode starts with a clean slate.
Hooks
Namespaced (Astro-style). Adding a new hook name is non-breaking; renaming an existing one bumps the apiVersion.
interface HoverHooks {
'hover:mode:activate'?: (ctx: ModeActivateCtx) => void | Promise<void>;
'hover:mode:deactivate'?: (ctx: ModeDeactivateCtx) => void | Promise<void>;
'hover:service:shutdown'?: (ctx: ShutdownCtx) => void | Promise<void>;
}
ctx (passed to every hook)
interface HoverHookCtxBase {
devRoot: string; // user's project root (Vite's server.config.root)
broadcast(event: { type: string; payload?: unknown }): void;
}
devRoot is where you should persist any plugin-local state (CA material, captured snapshots). The repo's root .gitignore already ignores .hover/ so a hidden directory under there is safe.
broadcast pushes a JSON event to every connected widget. Namespace your event types (<plugin>:<kind>).
ModeActivateCtx extras
interface ModeActivateCtx extends HoverHookCtxBase {
modeId: string;
setChromeProxy(proxy: { port: number; spki: string } | null): void;
setMcpServerEnv(id: string, env: Record<string, string>): void;
}
Use setChromeProxy(...) to tell the host that the secured Chrome needs proxy + SPKI flags for the duration of this mode.
Use setMcpServerEnv(...) to publish runtime data into a declared MCP server's env. @hover-dev/security for example boots its control plane in hover:mode:activate and writes the chosen port + auth token through this so the spawned MCP subprocess can talk to it without out-of-band coordination.
Conflict resolution
Hover refuses to start if it detects:
- Two plugins with the same
name - Two plugins contributing the same
mode.id - A plugin manifest with the wrong
apiVersion
Mode conflictsWith is currently informational; runtime enforcement lands in a future release.
Reference implementation
Read packages/security/src/index.ts end-to-end (~335 lines) — it exercises the manifest, mode, MCP server, Chrome flags, system prompt additions, hooks, widgetEntry resolution, and saveHandlers (Security spec output via writeSecuritySpec).
For the widget side of the same plugin, packages/security/src/widget.js (~225 lines) is the canonical example — see the Widget host API section above.