Write plugins for the Hermes desktop app: statusbar items, layout panes, command-palette commands, keybinds, routes, and themes. A plugin is a single plain-JavaScript ESM file the app loads at runtime — no build step, no repo changes. A plugin can also talk to its own Python backend namespace (ctx.rest/ctx.socket → /api/plugins/<id>); the general Python plugin system (~/.hermes/plugins/) is otherwise documented separately.
There are TWO on-disk doors, same contract and hot reload:
$HERMES_HOME/desktop-plugins/<id>/plugin.js — standalone desktop plugin. Loads enabled by default.$HERMES_HOME/plugins/<id>/desktop/plugin.js — the desktop HALF of a unified agent-plugin package: the same folder that carries the Python plugin (plugin.yaml) and its dashboard/plugin_api.py backend ships its desktop UI beside them, so one feature installs/uninstalls as one folder. The Electron shell copies that half into desktop-plugins/<id>/ (with a .hermes-package.json marker) — the ONLY root the renderer loads from — so the pane is app-level and does not come and go with the selected profile. This half is OPT-IN: it inventories in Capabilities → Plugins but stays off until the user toggles it (matching the Python half's plugins.enabled gate). Tell the user to flip it on after installing — don't debug a "plugin not appearing" report before checking that toggle.Full human reference (every export, area payloads, backend, security): website/docs/developer-guide/desktop-plugin-sdk.md.
$HERMES_HOME/desktop-plugins/ (usually ~/.hermes/desktop-plugins/).$HERMES_HOME/desktop-plugins/<name>/plugin.js from templates/plugin.js (in this skill directory) — that's ~/.hermes/... by default, or ~/.hermes/profiles/<profile>/... under a named profile. Keep <name> equal to the plugin id.The ONLY import surface is @hermes/plugin-sdk (plus react / react/jsx-runtime, which resolve to the app's own React — write UI with jsx() calls, not JSX syntax; the file is not compiled).
host.state.* — readonly reactive atoms: activeSessionId, busy, awaitingResponse, busyBySession, cwd, gateway (socket state, not turn-busy), model, profile, viewport, plus the tile-aware focused session atoms: focusedSessionId (runtime id — key for session.* RPC), focusedStoredSessionId (durable id — navigation / list matching), focusedSessionProfile (owner profile of the focused chat — prefer over profile for per-bot/profile readouts; profile is the gateway socket's home, which does not move with tab focus), and focusedUsage (live streamed UsageStats of the focused session, no RPC needed). busy is true while the focused chat is working after a send (thinking and streaming). awaitingResponse is true until the first assistant payload. busyBySession maps runtime session id → mid-turn, for rosters that watch every session. Prefer the focused atoms for any readout that should follow the user between tiles. Read with .get() in handlers, useValue(atom) in components.host.request(method, params) — gateway JSON-RPC (sessions, config, skills, cron — everything the app uses).host.onEvent(type, fn) — live gateway events ('*' for all). Returns a disposer.host.notify({ kind, message }), host.navigate(path), host.logs(...), host.status(), haptic('tap').ctx.register({ id, area, order?, render?, data? }) — contribute UI. Key areas: 'statusBar.right'/'statusBar.left' (chips), 'panes' (layout zones — set title and data: { placement, dock?, width?, height? }; the pane auto-joins a matching zone), PALETTE_AREA (⌘K commands), KEYBINDS_AREA (rebindable actions), THEMES_AREA (data is a full DesktopTheme).useTheme().setTheme(name) from a component, or requestTheme(name) from a callback with no component around it (a gateway event, a socket handler). requestTheme returns false for a name that doesn't resolve and leaves the appearance alone, so use it as the availability check instead of coercing the user back to the default skin.placement: 'left'|'right'|'bottom'|'main' is the semantic role — the pane stacks (tabs) with existing panes of that role. To land on a specific EDGE instead, add dock: { pane, pos } — the same gesture as dragging onto a pane's drop chip. pane is any pane id (workspace is the main thread; also sessions, terminal, files, review, logs), pos is 'top'|'bottom'|'left'|'right'|'center'. E.g. "below the conversation" = dock: { pane: 'workspace', pos: 'bottom' } — declare a height (e.g. '200px') so it doesn't take half the zone.area: ROUTES_AREA with data: { path: '/my-page' } and a render — the page mounts in the workspace (main) pane like any built-in view. Make it reachable with a sidebar nav row: ctx.register({ id: 'nav', area: SIDEBAR_NAV_AREA, data: { path: '/my-page', label: 'My Page', codicon: 'project' } }) (renders below Artifacts, lights up at the route) — and/or a PALETTE_AREA command calling host.navigate('/my-page').area: TRANSCRIPT_DIRECTIVE_AREA with data: { name: 'task', render: ({ attrs, streaming }) => jsx(...) } and the assistant can render your component inline in a chat message by emitting ::task{id="BB-12"} alone on its own line. Attrs are untrusted key="value" strings — validate them. Unclaimed/malformed directives fall back to plain text; core's own ::preview{file="…"} is the reference. After registering one, TELL the model it exists (a bundled skill or the user's instructions) — it won't discover the name on its own.ctx.storage.get/set/remove — persistence namespaced to your plugin.ctx.os — the curated OS door, attributed to your plugin: ctx.os.notify({ title, body?, silent?, icon?, activate?, onActivate?, actions? }) posts a native OS notification. Fires only while the user is away from Hermes (use host.notify for the in-app toast); gated by Settings ▸ Notifications ▸ "Plugin notifications" and throttled per plugin — reserve it for genuinely notable events. activate accepts a plugin deep link (hermes://index-network/intent/1), a hash path (/index-network/intent/1), or { path, params } — same resolver as OS deep links. Action buttons may set their own activate or an onAction callback (renderer-only; only the action id crosses IPC). ctx.os.openExternal(url), ctx.os.revealPath(path), and ctx.os.writeClipboard(text) resolve false (never throw) when the capability isn't available.ctx.i18n.register({ en, ja, ... }) — ship your OWN locale bundles, scoped to your plugin (never edit core en.ts). Values are literal strings or interpolator functions; nested trees are addressed by dot-path. Read them reactively in components with usePluginI18n(id) returning t('key', ...args) (re-renders on a locale switch), or via ctx.i18n.t in handlers/stores. Resolution follows the app's active locale, then your en, then the raw key.ctx.i18n.registerAppLocale('pl', { endonym: 'Polski', rtl?, translations }) — a LANGUAGE PACK: add or extend a language for the whole app. translations is a partial of the app catalog (nested, or flat dotted keys as in a pl.desktop.yaml); missing keys fall back to the bundled catalog then English; a string where English has a function takes positional {0}/{1} placeholders. Dropped on unload. Key set: locales/_keys.desktop.json (npm run i18n:keys). Registering never changes display.language. host.i18n.registerAppLocale is the ctx-less twin (returns the disposer); host.i18n.languageOptions() lists bundled ∪ registered ∪ backend languages.useQuery/useMutation/useQueryClient/queryClient (the app's ONE React Query client — cache, dedupe, refetchInterval, invalidate like core; never hand-roll a poll loop), plus atom/computed for plugin-local state.plugin_api.py (under ~/.hermes/plugins/<id>/dashboard/, manifest "api": "plugin_api.py"), reach it with ctx.rest('/path', { method?, body?, timeoutMs? }) and its live twin ctx.socket('/events', onMessage) — both scoped to /api/plugins/<id> by construction (traversal rejected). ctx.socket is a no-op on OAuth remotes, so always keep a polling fallback. The Python backend is imported only when the plugin is in plugins.enabled in config.yaml (separate from the in-app enable toggle). For gateway-wide data use host.request / host.onEvent instead.Contribute (mount-scoped): render jsx(Contribute, { area, id, children }) inside a component so page-owned chrome (e.g. a titlebar control in TITLEBAR_AREAS.center) leaves when the page unmounts — ctx.register is for permanent contributions.defaultEnabled: false on the default export ships an opt-in plugin: it inventories in Settings → Plugins, off until the user flips it on.Button, Input, Textarea, Select*, Switch, Checkbox, SegmentedControl, Tabs*, Dialog*, ConfirmDialog, DropdownMenu*, ContextMenu*, Popover*, Tip/Tooltip*, Badge, Kbd/KbdGroup, SearchField, ScrollArea, Separator, Skeleton, GlyphSpinner, EmptyState, ErrorState, CopyButton, StatusDot, LogView, Codicon, DecodeText, plus cn and icons.*. Prefer these over hand-rolled elements so the plugin looks native; style with theme vars, never hardcoded colors.id; the folder name must match.templates/plugin.js; keep the default export shape ({ id, name, register(ctx) }).area: 'panes' with a placement hint and a render returning your component — the app places it into a sensible zone automatically; the user can drag it anywhere afterwards.host.request and/or subscribe with host.onEvent; never poll faster than a few seconds.#000, black, rgb(...)). Panes already sit on the app's editor background — leave the background alone and use theme variables for everything else: var(--ui-text-secondary), var(--ui-text-quaternary), var(--ui-stroke-secondary), var(--ui-accent). For canvas drawing, resolve them once with getComputedStyle(canvas).getPropertyValue('--ui-accent').StatusDot) is a ReferenceError at render. Double-check every identifier in your jsx() calls appears in the import line.ResizeObserver and re-size the canvas (width/height attributes, not just CSS) — panes resize constantly (sash drags, layout switches); a mount-time-only size leaves blank space or blurry scaling.jsx('div', { children: ... }) from react/jsx-runtime.@hermes/plugin-sdk, react, and react/jsx-runtime; other specifiers fail to resolve.$atom.get()), never from render closures — rapid events will otherwise see stale values.useValue) only in the leaf that renders the value.