Files
hermes-agent/website/docs/developer-guide/desktop-plugin-sdk.md
Teknium 9bcbe7b5df feat(i18n): pluggable, layered language packs across core, Desktop and TUI (#126296)
* feat(i18n): layered catalogs — plugin packs and user overlay over bundled locales

* feat(tui): i18n layer — en catalog, nanostore runtime, RPC pack loader, _keys.tui.json emitter

ui-tui/src/i18n/: en.ts (facade over topical siblings under en/), types.ts
(Translations + dotted TranslationKey derived from en), runtime.ts ($locale/
$catalog atoms, translateFrom active→en→key, pack merge with string→fn
wrapping for {0}/{1} placeholders), loader.ts (display.language →
i18n.catalog {lang, surface:'tui'}, English when the method is missing),
useT()/useLocale() hooks, t() for non-React code. useConfigSync feeds the
loader from the existing config.get full hydration. `npm run i18n:keys`
writes locales/_keys.tui.json (sorted flat key list) and runs before build.

* feat(plugins): provides_locales manifest field, ctx.register_locale/register_locale_dir, manifest-only language packs

* chore(tui): split en catalog siblings by lane (slash sibling)

* feat(plugins): validate language packs — parse, text-only, key-subset WARN against en / _keys exports

* feat(tui_gateway): i18n.languages / i18n.catalog RPC + regenerated contracts

* feat(config): display.language accepts any supported_languages() id, refuses unknown ids with the list

* docs(i18n): language packs user guide, pluggable display.language, plugin developer section, AGENTS notes

* feat(plugins): report language-pack layers in the mid-run activation summary

* feat(tui): wire status bar, composer placeholders, hotkey help and approval/clarify/confirm prompts through i18n

StatusRule maps compared state values (ready/running…/summoning) to catalog
text at render via displayStatus(); hotkeys()/placeholder() resolve lazily so
a pack that arrives after boot applies. Catalog grows to 81 keys.

* feat(desktop): pluggable app locales — registry, host.i18n.registerAppLocale, backend packs, keys emitter

- Locale widens to string (BundledLocale keeps the union); TRANSLATIONS stays
  the bundled record and every consumer resolves through the registry.
- src/i18n/registry.ts: registerAppLocale(id, {endonym, rtl, translations})
  layers partial packs (nested or flat dotted) over bundled/en via
  mergeTranslations; a string over a function-valued en entry becomes a
  positional {0}/{1} formatter; $appLocaleVersion bumps so translators
  re-render; per-source disposers + replaceAppLocaleSource for atomic swaps.
- Backend packs: i18n.languages + i18n.catalog {surface:'desktop'} feed the
  registry as source 'backend' (method-not-found is silent); re-synced on
  socket open, display.language change and profile switch. A saved pack-only
  language is promoted once its pack registers.
- SDK: host.i18n.registerAppLocale / languageOptions; ctx.i18n.registerAppLocale
  tracked for unload. Docs in the desktop plugin SDK guide + skill reference.
- Language switcher lists bundled ∪ registered ∪ backend, endonym-only; RTL
  from the registry (applyDocumentLocale takes rtl).
- npm run i18n:keys emits locales/_keys.desktop.json (wired into build).

* i18n(cli): route /topup + /subscription copy through t() (cli.billing.*, cli.subscription.*)

Module-level copy tables and modal choice tuples in cli_billing_mixin.py froze
English at import, before display.language was known. They are now key tables /
builder functions evaluated at call time; every user-facing line in the /usage
balance block, /subscription and the five /topup screens reads the catalog.
Choice VALUES stay English identifiers. Fragment-assembled status lines
(Plan: … → cancels · $x left · renews …) become full templates.

* i18n(gateway): exec-approval card contract + base/run/run_busy/run_inbound replies through t()

- base_exec_approval: EA_* English constants stay; add ea_header_text()/ea_reason_label_text()/
  ea_smart_deny_line_text()/ea_default_reason_text()/ea_action_labels()/approval_timed_out_notice()
  accessors; deadline + timed-out notice resolve via gateway.exec_approval.*
- BasePlatformAdapter._EA_HEADER/_EA_REASON_LABEL/_EA_SMART_DENY_LINE/_EA_ACTION_LABELS become
  properties (adapters still shadow them with markup class attrs)
- run.py: provider error replies table holds catalog keys; _CONTEXT_OVERFLOW_REPLY -> _context_overflow_reply()
- run_busy/run_inbound: typed approval + slash-confirm matchers accept English ∪ approval.inputs.* (t())
- locales/en.yaml: gateway.exec_approval/busy/errors/... namespaces

* i18n(cli): wire modal, loops, agent-setup mixins through t() (cli.* keys)

* i18n(platforms): route Slack, Matrix and Feishu user-facing text through t()

Exec-approval markup overrides (_EA_HEADER/_EA_REASON_LABEL/_EA_SMART_DENY_LINE/
_EA_ACTION_LABELS) become per-call properties over the shared
gateway.exec_approval.* contract keys, so Slack's 3000-char section budget
measures the resolved template. Slack _APPROVAL_DECISIONS/_CONFIRM_DECISIONS,
Feishu _APPROVAL_LABEL_MAP and Matrix _EA_LEGEND/_EA_TYPED_HINT turn into
key tables resolved at click time; the Matrix typed hints become whole
sentences per offered tier instead of spliced fragments. Slack button labels
are cut to 75 chars and select placeholders to 150 after translation; the
model-facing clarify fallback answer ('choice N') stays English while the
card copy localizes.

locales/en.yaml gains the gateway.exec_approval.* contract keys plus the
platform.shared.* / platform.slack.* / platform.matrix.* / platform.feishu.*
namespaces (and the keys for the other adapters wired in follow-up commits).

* i18n(gateway): run_turn / run_turn_runner / approval-settle copy through t()

- status hints, proxy errors, background task notices, progress heartbeats, session info lines
- tool progress chrome (tool_head/tool_pending/tool_preview/tool_verbose) shared by base.format_tool_event
- run_turn_runner:1406 Chinese clarify placeholder -> gateway.clarify.native_stream_placeholder (zh text kept in zh.yaml)
- _UNEXPECTED_SILENCE_REPLY/_CLARIFY_EXPIRED_NOTICE -> accessor functions

* i18n(platforms): route Google Chat and Teams user-facing text through t()

Google Chat clarify card, typing placeholder, orphan-card labels and the whole
/setup-files reply set (module constants become platform.google_chat.setup_files.*
keys resolved at reply time). The attachment-fallback notice that shipped
hardcoded in Spanish is keyed with an English en value; es.yaml carries the
original Spanish text for those four keys.

Teams approval card header/reason use the gateway.exec_approval.* contract,
_APPROVAL_LABELS becomes a key table resolved at click time, and the meeting
summary writer resolves its section headings/fallbacks per render.

* i18n(platforms): route LINE, WeCom, email, DingTalk, IRC and Home Assistant text through t()

LINE default copy constants become catalog keys resolved in __init__ (the
LINE_*_TEXT / extra.* operator overrides still win); the busy-ack bypass
matcher keys on the leading emoji marker only, so it keeps firing once the
gateway busy heads are localized. WeCom media size/format notices that shipped
hardcoded in Chinese are keyed with English en values and zh.yaml carries the
original Chinese text. DingTalk emotion bubbles resolve per send.

* i18n(cli): route /model switch output and -q status lines through t() (cli.model.*, cli.single_query.*)

Switch-summary labels shared with the gateway reuse gateway.model.* keys
(provider/context/max-output/capabilities/prompt-caching); CLI-only variants
(glyph or no-backtick forms) live under cli.model.*. The hand-padded /model usage
block becomes a (form, description-key) table padded at render time so the
command syntax stays fixed while descriptions translate. -q 'Error:' reuses
gateway.model.error_prefix.

* i18n(cli): route TUI panel/hint/placeholder copy through t() (cli.tui.*)

_APPROVAL_CHOICE_LABELS and _TUI_MODAL_HINTS become key tables resolved at
render time; vault/sudo panel bodies are one catalog value per panel split on
newline; inline plurals use <key>_one/<key>_other. Adds the cli.* namespace
(shared/tui/voice/render/subagents/dock) to locales/en.yaml.

* i18n(cli): voice/wake-word CLI copy through t() (cli.voice.*)

RuntimeError texts raised in _voice_start_recording are human copy (callers
print {e}) and are keyed; the Termux requirement-check match stays English.
Wake state ids stay internal, only their labels localize.

* i18n(cli): live-work dock, subagent monitor and render copy through t()

cli.subagents.* / cli.dock.* / cli.render.*; count fragments pluralize via
_one/_other keys, verdict table holds keys resolved at paint time so width
clipping measures the translated text.

* i18n(gateway): unauthorized/pairing, voice, topics, shutdown, startup, notifications, kanban pings through t()

* i18n(cli): move tips + composer placeholders into the catalog (tips.tNNN / tips.placeholder.pNN)

get_random_tip()/get_random_composer_placeholder() pick a key from the English catalog
(the parity baseline, probed once per process) and resolve it through t() for the active
language, so language packs translate tips like any other string. Also lands the cli.*
en.yaml namespace consumed by the CLI info/help/error-copy wiring in the next commit.

* i18n(cli): wire chat-turn + session mixins through t(); kanban log trimmer matches t() output

* test(cli): assert TUI/dock/voice copy via t(key); prove labels resolve at render time

Pinned-English assertions in the approval-UI, live-work dock and voice tests now
go through the catalog. New test swaps the catalog after import and checks the
approval panel + hint row follow it (the reason _APPROVAL_CHOICE_LABELS and
_TUI_MODAL_HINTS became key tables).

* i18n(cli): route CLI info/help/error copy through t() (cli.* namespace)

cli_info_mixin: /help consumes CommandDef.describe() (added to commands.py: slash.<name>.description
with fallback to .description), section titles/skill/quick-command headers, /tools, /toolsets,
/usage labels, /context, /whoami, /insights, /gateway status, tool-progress labels, bang-shell
denials, MCP config-watch + /reload-mcp confirm/reload lines, /reload-skills, and the session-store
warning all read the catalog at call time (module-level label tables became functions so the
active language is honoured after startup). cli.py: worktree cleanup, tirith warning, show_config
(labels re-padded at print time), quick/plugin/skill slash-command errors, ambiguous-command hint,
stdin error, gateway start, profile warning. cli_chat_error_copy / cli_unknown_command /
cli_output / cli_init_mixin: chat error panel copy, did-you-mean lines, n-more / yes-no prompt
(localized affirmative initial alongside 'y'), unknown-toolsets warning.

* i18n(w1a): wire agent display/explainers/approval + slash registry/help through t()

- hermes_cli/commands.py: CommandDef.describe() resolves slash.<name>.description
  at call time; category labels via slash.category.*; help/alias/usage suffixes
  via slash.shared.*; gateway_help_lines and commands_platforms/slash_exec use them.
- agent/display.py: display.verb.* resolved at call time via get_tool_verb();
  bridge/spinner/thinking-verb/cute-row/failure/preview/diff text via display.*.
- agent/turn_explainers.py: exit-reason / persistence-cause tables become call-time
  lookups (explainer.exit.*, explainer.persistence.*, explainer.file_mutation.*).
- agent/background_review.py, session_activity.py, context_breakdown.py,
  status_output.py: review summaries, iteration progress, context notices.
- tools/approval.py, approval_context.py: approval.summary.*, approval.noun.*,
  approval.window.* pluralized keys.
- gateway/slash_commands*.py: remaining raw strings (busy, whoami, platform,
  bundles, memory, skills, approvals, set_home, diff, update, debug, profile,
  heartbeat, refine, review, subgoal, loop, retry, compress codex path, save,
  sessions, model guard/errors, agents rows, topup, login). HISTORY_UNREADABLE
  keeps its English constant; callers use history_unreadable() ->
  gateway.shared.history_unreadable.
- locales/en.yaml: new approval/display/explainer/slash blocks + gateway leftovers.

* i18n(telegram): route adapter chat copy through t()

Approval card (header/reason/smart-deny as HTML-escaped properties), inline
button labels, callback toasts (cut at Telegram's 200-char cap), model/choice
pickers, clarify/update/slash-confirm prompts, gmail-triage labels and the
inbound-media failure notice now come from the catalog. _UNAUTHORIZED is a
lazy _unauthorized() so the import no longer binds a language. The command
menu carries a language+payload fingerprint (forum scopes re-register on
change) and BotCommand descriptions are cut at 256.

Adds gateway.exec_approval.* (WAVE2 contract), platform.telegram.*,
platform.discord.* and the slash.*.description keys the Discord table shares
with the CLI registry to locales/en.yaml.

* i18n(gateway/platforms): whatsapp_cloud, yuanbao, weixin, signal, api_server copy through t()

- whatsapp_cloud: clarify list/buttons, approve/deny + slash-confirm labels via platform.whatsapp.* (t()-then-truncate at 20/24/72 caps); _EA_HEADER becomes a property wrapping ea_header_text()
- yuanbao: SLOW_RESPONSE_MESSAGE -> slow_response_message() (platform.yuanbao.slow_response_notice; zh keeps the original text); cron-wrapper markers centralized as module constants for strip_cron_wrapper
- api_server: PROVIDER_AUTH_FAILED_LABEL/PROVIDER_RATE_LIMITED_LABEL stay English for run.py matchers; user_text() renders via t()
- signal/_format_wait, weixin voice caption, openai_routes transformed notice
- run_turn: second _UNEXPECTED_SILENCE_REPLY consumer -> accessor

* i18n(discord): route adapter chat copy through t()

Native slash-command table becomes _NATIVE_SLASH_COMMAND_SPECS holding catalog
keys; _native_slash_commands() resolves descriptions, parameter descriptions
and Choice names for the active language, each cut at Discord's 100-char cap,
and the app-command sync fingerprint now includes get_language() so a
display.language change re-syncs. Exec-approval card (gateway.exec_approval.*
contract), slash-confirm / clarify / update views, model+choice pickers,
thread creation, forum titles, voice acks, the response-truncation notice,
the unauthorized-slash security alert and the media upload-size notices all
read from platform.discord.*. Decorator-declared button labels are relabelled
in __init__ (80-char cap); embed titles cut at 256, select placeholders at
150, option label/description at 100. _UNAUTHORIZED is a lazy _unauthorized().

* i18n(cli): wire status-bar, stream, terminal mixins + terminal_input through t(); rename kwargs that shadow t(key)

* i18n: wire hermes_cli/cli_commands_mixin.py slash-command copy through t()

- 431 new leaves under cli.commands.<cmd>.* in locales/en.yaml; 12 rows reuse
  existing gateway.* keys (rollback, diff, resume, branch, btw, model, reasoning)
  via a _gt() helper so CLI and gateway replies stay identical.
- Module-level English tables (_BUSY_MODE_*, _REASONING_TOGGLES, _HATCH_PROGRESS,
  _DIFF_LABELS, _LOCAL_ENGINE_LINES) become call-time catalog lookups keyed by id.
- Verb tables (Enabling/Disabling, Paused/Resumed/Triggered, planned/done,
  Updating/Generating) are one full template per variant; plurals use
  <key>_one/<key>_other via _tn(); hand-padded column labels (/snapshot list)
  translate the value and re-pad at the call site.
- Multi-line usage blocks are single catalog values split with _lines().
- Model-facing system notes and DB-stored reasons stay English (EXCLUDED).

* tests: assert /handoff, /worktree, /login CLI copy via t(key) instead of pinned English

* test(i18n): pin Telegram/Discord adapter catalog wiring

Lazy unauthorized notice, exec-approval contract keys, HTML escaping before
Telegram <b> wrapping, 200-char toast / 256-char BotCommand caps, Discord
100-char app-command text and 80-char button caps, and language-bearing
command-menu fingerprints on both platforms.

* i18n: reconcile cli.shared on/off vs enabled/disabled after lane merge

* i18n: describe() in TUI-gateway slash listings; localize TUI exit resume hint

* i18n(tr): translate bundled catalog + tui pack

* i18n(ja): translate bundled catalog + tui pack

* i18n(ko): translate bundled catalog + tui pack

* i18n(zh): translate bundled catalog + tui pack

* i18n(fr): translate bundled catalog + tui pack

* i18n(af): translate bundled catalog + tui pack

* i18n(uk): translate bundled catalog + tui pack

* i18n(ar): translate bundled catalog + tui pack

* i18n(pt): translate bundled catalog + tui pack

* i18n(it): translate bundled catalog + tui pack

* i18n(es): translate bundled catalog + tui pack

* i18n(zh-hant): translate bundled catalog + tui pack

* i18n(ru): translate bundled catalog + tui pack

* i18n(hu): translate bundled catalog + tui pack

* i18n(hu): translate pre-existing English-valued leftovers (kanban wake, /context, /status, fast labels)

* i18n(de): translate bundled catalog + tui pack

* i18n(ga): translate bundled catalog + tui pack

* test(i18n): fixture matches _normalize_lang(lang, home) signature

* i18n(tui): scaffold userMessages/slashCmd en siblings

* i18n(tui): wire secure prompts + content tables

* feat(tui): i18n — wire billing, subscription, connection-setup and journey overlays

Adds en siblings billing.ts / subscription.ts / connection.ts (namespaces
billing, subscription, connection, journey) and routes every user-facing
literal in billingOverlay, subscriptionOverlay, connectionSetupOverlay and
journey through useT()/messages(). Module-level label tables became lazy
(scopeStillDeniedResult(), verbOf(T, action)); auto-reload rows dispatch on
stable ids instead of label text. Regenerates locales/_keys.tui.json.

* i18n(tui): wire slash ops/wake replies

* i18n(tui): wire pickers (modelPicker, activeSessionSwitcher, petPicker)

* i18n(tui): wire slash core/debug/setup replies

* i18n(tui): wire hubs (agents overlay/panel/controls, skills, plugins)

* i18n(tui): wire slash session/topup/subscription replies

* i18n(tui): wire chat bits (branding, thinking, messageLine, loaders, todo, queued, banner, entry)

* i18n(tui): register t3 siblings (pickers, hubs, secure, content, chatBits) and regenerate keys

* i18n(tui): wire userMessages copy through the userMessages namespace

* i18n(tui): lazy-copy test for userMessages, regenerate _keys.tui.json

* i18n(tui): wire session/gateway/lib text through the TUI catalog (lane t2)

Adds en siblings session.ts, gatewayMsg.ts, libText.ts (namespaces session,
gatewayMsg, libText) and routes user-facing literals in app/{useMainApp,
useSessionLifecycle,useInputHandlers,turnController,createServerRequestHandler,
setupHandoff,createGatewayEventHandler}.ts, gatewayClient displayed reasons,
lib/*, domain/*, hooks/* through t()/messages(). Status-bar state values that
code compares against, backend-matched strings, log lines, model-bound text,
and machine 'error:' prefixes stay literal. Regenerates locales/_keys.tui.json
(232 keys).

* i18n: serve bundled locales/<lang>.tui.yaml under overlay/packs; TUI pack parity test; regen _keys.tui.json (1250)

* i18n: translate pre-existing English stubs in bundled locales (424 leaves, 14 locales)

* tui: i18n-export-en script (English templates for pack translators)

* docs(i18n): bundled TUI packs are the bottom layer of the tui surface

* i18n(ru): translate TUI pack

* i18n(ar): translate TUI pack

* i18n(es): translate TUI pack

* i18n(pt): translate TUI pack

* i18n(ko): translate TUI pack

* i18n(de): translate TUI pack

* i18n(ja): translate TUI pack

* i18n(fr): translate TUI pack

* i18n(tr): translate TUI pack

* i18n(it): translate TUI pack

* i18n(zh): translate TUI pack

* i18n(zh-hant): translate TUI pack

* i18n(hu): translate TUI pack

* i18n(uk): translate TUI pack

1,169 missing keys translated; 81 pre-existing kept byte-identical. Parity OK missing=0 extra=0 placeholder_mismatch=0 empty=0.

Deliberately identical to en: chatBits.branding.mcpSummary ({0} MCP), chatBits.thinking.agentsHint ((/agents)), session.main.voiceStt (◉ STT), session.main.voiceTtsSuffix ( [tts]), slashCmd.core.help.tuiSection (TUI), slashCmd.core.history.hermesTag (Hermes #{0}), slashCmd.debug.heapdump.heapPath (heapdump: {0}), slashCmd.debug.mem.rss (rss), subscription.stepUp.title (Remote Spending — product feature name, as in core catalog), content.faces.* (glyph-only kaomoji).

* i18n(ga): translate TUI pack

* i18n(af): translate TUI pack

* plugin_guard: locale catalogs in language packs step down the agent-config family

A translated status line such as "Updating AGENTS.md" in locales/<lang>.yaml is UI text the loader
reads as a string leaf; it cannot edit a file. The bundled en.yaml itself tripped agent_config_mod
at critical, making any faithful language pack uninstallable. Injection shapes keep full severity.

* plugin_validate_locales: read key exports with utf-8-sig (Windows footgun lint)

* i18n(relay): route relay adapter prompt copy through t(); drop dead import-bound approval header

Adds platform.relay.* (5 keys) to en and all 16 bundled locales, reusing the sibling platform
translations for the confirm buttons and the Other option.

* ci: fix TUI import order, MDX table pipe, main's overflow-warning wording in all locales; fresh-install fixture carries the i18n kernel

- ui-tui/src/i18n/en.ts: perfectionist/sort-imports (slash before slashCmd)
- docs plugins/index.md: escape the | inside the provides_locales table cell (MDX parsed <id> as JSX)
- display.notice.uncompressed_context_overflow: adopt main's wording (names compression.enabled: false
  and /compact) in en + 16 locales; the guardrail test pins that phrase
- tests/scripts/test_fresh_source_install.py: the installer tail now resolves CLI text through
  agent.i18n, so the fixture tree carries the i18n kernel + en.yaml (not the agent runtime)

* docs(desktop-plugin-sdk): double-backtick the template-literal example (MDX evaluated ${n})

* test(e2e): display.language is validated against the live language set; exclude it from the arbitrary-string set property

* commands: keep the localized COMMANDS/COMMANDS_BY_CATEGORY module __getattr__ after the compat block removal

* build: never write locales/_keys.*.json from the desktop/TUI builds; regenerate the committed desktop key export

The desktop build regenerated locales/_keys.desktop.json in the checkout, so a
hermes update that rebuilt the app left the tree dirty (Desktop update E2E:
'M locales/_keys.desktop.json'). The key exports are committed artifacts pinned
to en.ts by apps/desktop/scripts/i18n-keys.test.mjs and ui-tui i18n:keys:check;
builds read them, never write them. Regenerated after main's new desktop strings.

* test: unbreak two main-red timing tests the PR merge-ref inherits

- test_local_runtime racing fake publishes the modern state record (legacy pid-only
  records are rejected since 65ff3ad353; main has been red on this test since)
- test_run_progress_topics ManyProgressLinesAgent waits for the first bubble instead of
  a fixed 0.35s, which a loaded CI runner does not always meet

* chore(i18n): regenerate desktop key catalog for main's new strings (model pricing, copy changelog)

* test(e2e): torture-chamber fd monitor confirms a deleted sidecar is still held before calling it a leak

SQLite's WAL last-close unlinks -shm before closing its descriptor (unixShmUnmap, then
unixShmPurge), so a healthy close shows a (deleted) -shm for microseconds; the 20ms poll
occasionally caught that window on the short-lived opener and failed the episode.

* chore(i18n): regenerate desktop key catalog for main's telemetry/consent strings

* chore(i18n): regenerate desktop key catalog after main sync

---------

Co-authored-by: Teknium <teknium@nousresearch.com>
2026-09-28 14:16:18 -07:00

84 KiB
Raw Blame History

sidebar_label, title, description
sidebar_label title description
Desktop Plugin SDK Desktop Plugin SDK (@hermes/plugin-sdk) Extend the native Hermes Desktop app — panes, pages, sidebar nav, status bar, palette commands, keybinds, themes, and a scoped backend namespace, with one import and no build step.

Desktop Plugin SDK

The native Hermes Desktop app is contribution-driven: every surface in the window — panes, routes, sidebar nav, status-bar items, palette entries, keybinds, themes — registers into one central registry. Core registers its surfaces exactly the way a plugin does, so the plugin story is the real one, not a bolted-on afterthought.

A desktop plugin is a single ESM file that default-exports a HermesPlugin. It imports one module — @hermes/plugin-sdk — and gets everything: the app's live state, the gateway JSON-RPC door, a scoped REST/socket backend namespace, React Query, and the app's own UI kit so plugin UI looks native by default. No repo clone, no npm run build, no patching app source. Drop the file in $HERMES_HOME/desktop-plugins/<id>/plugin.js and the app loads it within seconds and hot-reloads every save.

:::warning This is not the web-dashboard plugin SDK "Plugin" means several unrelated things across Hermes. This page is the native desktop app (hermes desktop) SDK — the @hermes/plugin-sdk module and $HERMES_HOME/desktop-plugins/. The web dashboard (hermes dashboard) has its own, unrelated plugin system on window.__HERMES_PLUGIN_SDK__ with a manifest.json — documented at Extending the Dashboard. Python CLI/gateway plugins are documented at Build a Hermes Plugin. The three do not share code, APIs, or delivery. Only the backend plugin_api.py namespace (/api/plugins/<id>) is shared between the desktop and dashboard SDKs. :::

Mental model

The SDK follows the VS Code module model. A plugin author imports exactly one module and never touches app internals (they are lint-fenced out of a bundled plugin, and fail to resolve in a disk plugin). Capability comes in tiers:

  • host.state.* — readonly views over the app's live state (nanostore atoms): active session, per-session turn-busy, cwd, gateway socket status, model, profile, viewport. gateway is the WebSocket, not turn-busy.
  • host.* actions — curated safe verbs: toast, navigate, tail logs, restart the gateway, subscribe to the gateway event stream.
  • host.request — the gateway JSON-RPC door: sessions, config, skills, cron — everything the app itself calls.
  • captureGatewayFileDownload() — capture a gateway file-save action immediately before starting a REST read, and retain it alongside the returned data. The action (storedPath, suggestedName) => Promise<void> keeps that read's connection/profile scope even if the user switches hosts before clicking. Invoke only on an explicit user download gesture, using the backend's persisted file path, never a guessed workspace path. Electron handles authenticated streaming, the native save dialog, and older-gateway fallback; plugins never receive credentials or open remote paths with file://. The host shows the same "Saved" / "Download failed" toasts as the Files panel and stays quiet on cancel; the promise settles when the save does and never rejects.
  • ctx.rest / ctx.socket — your plugin's own backend namespace (/api/plugins/<id>) if you ship a plugin_api.py.
  • ui.* — the design language: the app's real components, theme variables, icons, and formatters, so your UI matches the app pixel-for-pixel.

Two delivery modes

Mode Where Who Build step
Disk (recommended) $HERMES_HOME/desktop-plugins/<id>/plugin.js users, agents none — plain ESM, loaded uncompiled
Unified package $HERMES_HOME/plugins/<id>/desktop/plugin.js plugins that also ship agent-side code none — same disk pipeline
Bundled apps/desktop/src/plugins/<id>/plugin.tsx in-tree, shipped with the app the app's own Vite build

All three take the same HermesPlugin contract, appear in Capabilities → Plugins, and enable/disable live. A unified package is just the disk door scanning inside your agent plugin's folder — see One package, both SDKs. Everything on this page is written against the disk door (what you and the agent write); Bundled plugins notes the two differences. Radio ships as a bundled SDK-only plugin, off by default. Enable it in Capabilities → Plugins for free live streams, station search, and status-bar playback controls with an audio-reactive waveform. It uses the existing plugin toggle and contributes nothing while disabled. Reference demos live in the companion hermes-example-plugins repo.

Quick start — your first plugin

Create $HERMES_HOME/desktop-plugins/hello/plugin.js (that's ~/.hermes/... by default). Desktop plugins are app-level — one root for every profile, gateway, or remote machine the window connects to. The folder name must equal the plugin id.

// ~/.hermes/desktop-plugins/hello/plugin.js
import { host, haptic, useValue } from '@hermes/plugin-sdk'
import { jsx, jsxs } from 'react/jsx-runtime'

function HelloPane() {
  const gateway = useValue(host.state.gateway)

  return jsxs('div', {
    className: 'flex h-full flex-col gap-2 p-3 text-sm',
    children: [
      jsx('div', { className: 'font-medium', children: 'Hello, Hermes' }),
      jsx('div', {
        className: 'text-(--ui-text-tertiary)',
        children: `gateway: ${gateway}`
      })
    ]
  })
}

export default {
  id: 'hello', // must match the folder name
  name: 'Hello',
  register(ctx) {
    ctx.register({
      id: 'pane',
      area: 'panes',
      title: 'hello',
      data: { placement: 'right', width: '260px' },
      render: () => jsx(HelloPane, {})
    })
    ctx.register({
      id: 'chip',
      area: 'statusBar.right',
      order: 130,
      render: () =>
        jsx('button', {
          type: 'button',
          className: 'px-1.5 text-[0.6875rem] text-(--ui-text-tertiary)',
          onClick: () => {
            haptic('tap')
            host.notify({ kind: 'info', message: 'Hello from my plugin!' })
          },
          children: 'hello'
        })
    })
  }
}

Save it. The app watches desktop-plugins/, loads the file within a few seconds, and hot-reloads every later save in place. If it doesn't appear, run ⌘K → Reload desktop plugins. If loading fails, a toast names the error — fix and save again.

:::note No JSX, no build The disk file is loaded uncompiled, so JSX syntax will not parse. Write UI with jsx() / jsxs() calls from react/jsx-runtime (or React.createElement). The only importable specifiers are @hermes/plugin-sdk, react, and react/jsx-runtime — everything else fails to resolve, on purpose. :::

The plugin contract

A plugin default-exports a HermesPlugin:

interface HermesPlugin {
  /** Stable slug — becomes the `plugin:<id>` source and the id namespace. */
  id: string
  /** Human name for Settings / about UI. Defaults to `id`. */
  name?: string
  /** Registers on load when the user hasn't chosen (default true). Set false
   *  for opt-in plugins: they inventory in Capabilities ▸ Plugins, off until the
   *  user flips the switch. */
  defaultEnabled?: boolean
  /** Called once at load; wire contributions through `ctx`. */
  register: (ctx: PluginContext) => void
}

register receives a scoped PluginContext. It never touches the registry directly — the context auto-tags provenance (source: 'plugin:<id>') and namespaces every contribution id (<id>:<localId>), so two plugins can never collide.

interface PluginContext {
  /** Resolved source tag, e.g. `'plugin:hello'`. */
  readonly source: string
  /** Register one contribution (id namespaced, source stamped). Returns a disposer. */
  register: (c: PluginContribution) => () => void
  /** Register several at once; the returned disposer removes all of them. */
  registerMany: (cs: PluginContribution[]) => () => void
  /** REST to this plugin's own backend namespace (`/api/plugins/<id>`). */
  rest: <T>(path: string, opts?: PluginRestOptions) => Promise<T>
  /** Live WebSocket to this plugin's own namespace. Returns a disposer. */
  socket: (path: string, onMessage: (data: unknown) => void) => () => void
  /** Gateway event stream by type (`'*'` = all). Tracked: removed on unload/reload/disable. */
  onEvent: (type: string, listener: (event: GatewayEvent) => void) => () => void
  /** Any other cleanup to run on unload/reload/disable (store subscriptions, injected DOM). */
  onDispose: (fn: () => void) => void
  /** Scoped timers and DOM listeners — cleared with the plugin. Each returns a disposer. */
  setTimeout: (fn: () => void, ms: number) => () => void
  setInterval: (fn: () => void, ms: number) => () => void
  addEventListener: (target: EventTarget, type: string, listener: EventListener, options?: AddEventListenerOptions | boolean) => () => void
  /** The curated OS door: native notification, open-external, reveal-in-file-manager, clipboard. */
  os: PluginOs
  /** Plugin-scoped JSON persistence (keys live under `hermes.plugin.<id>.`). */
  storage: PluginStorage
}

A contribution is the one primitive every surface shares:

interface Contribution {
  id: string          // you write the local id; the host namespaces it
  area: string        // WHERE it goes (a contribution-area constant)
  title?: string
  order?: number      // sort within the area (lower = earlier)
  when?: () => boolean // dynamic visibility; re-evaluated by the area
  enabled?: boolean
  render?: () => ReactNode  // the component to mount
  data?: unknown      // area-specific payload (see the cookbook)
}

You provide render, data, or both, depending on the area.

Contribution areas — the cookbook

Import the area constants from the SDK; each area has its own data payload.

Surface area You provide
Layout pane PANES_AREA ('panes') title + render + data: { placement, dock?, width?, height? }
Full page ROUTES_AREA data: { path } + render
Sidebar nav SIDEBAR_NAV_AREA data: { path, label, codicon }
Status bar STATUSBAR_AREAS.left / .right render (or data as StatusbarItem)
Title bar TITLEBAR_AREAS.left / .center / .right data as TitlebarTool, or a mount-scoped <Contribute>
Page header WORKSPACE_PAGE_HEADER_AREA <WorkspacePageHeaderControl> inside your page (inline in a split tile)
⌘K palette PALETTE_AREA data: PaletteContribution
Keybind KEYBINDS_AREA data: KeybindContribution
Theme THEMES_AREA data as a DesktopTheme
Composer COMPOSER_AREAS.* render slots, or middleware / attachment providers
Appearance settings APPEARANCE_AREAS.extra render — controls appended to Settings → Appearance

Panes

A pane is a tile in the layout tree. placement is the semantic role — the pane stacks (as tabs) with existing panes of that role; the user can drag it anywhere afterward.

ctx.register({
  id: 'pane',
  area: 'panes',
  title: 'my pane',
  data: { placement: 'right', width: '260px' },
  render: () => jsx(MyPane, {})
})

placement is 'main' | 'left' | 'right' | 'top' | 'bottom'. To land on a specific edge instead of stacking, add a dock gesture — the same thing as dragging onto a pane's drop chip:

// Below the conversation, 200px tall.
data: {
  placement: 'bottom',
  dock: { pane: 'workspace', pos: 'bottom' },
  height: '200px'
}

dock.pane is any pane id (workspace is the main thread; also sessions, terminal, files, review, logs); dock.pos is 'top' | 'bottom' | 'left' | 'right' | 'center'. Declare a width/height so the pane doesn't claim half the zone.

Closing the only pane contributed by a plugin disables that plugin, which can be re-enabled from Capabilities → Plugins. When a plugin contributes multiple panes, closing one dismisses only that pane and leaves the plugin's other panes, commands, and middleware active. Reset layout restores dismissed contributed panes.

Pages and sidebar nav

A route mounts a full page in the workspace pane, like any built-in view. Pair it with a sidebar nav row (and/or a palette command) to make it reachable.

import { ROUTES_AREA, SIDEBAR_NAV_AREA } from '@hermes/plugin-sdk'

ctx.registerMany([
  {
    id: 'page',
    area: ROUTES_AREA,
    data: { path: '/my-page' },
    render: () => jsx(MyPage, {})
  },
  {
    id: 'nav',
    area: SIDEBAR_NAV_AREA,
    data: { path: '/my-page', label: 'My Page', codicon: 'project' }
  }
])

codicon is a VS Code codicon id. Navigate to a route from anywhere with host.navigate('/my-page').

Status bar and title bar

Status-bar items render into the left or right cluster of the bottom bar. Simplest is a render function; for a plain button use data as a StatusbarItem ({ id, label?, icon?, detail?, variant?, menuItems?, … }).

import { STATUSBAR_AREAS, TITLEBAR_AREAS } from '@hermes/plugin-sdk'

ctx.register({
  id: 'count',
  area: STATUSBAR_AREAS.right,
  order: 120,
  render: () => jsx(MyStatus, {})
})

Title-bar tools live in TITLEBAR_AREAS.left | .center | .right as TitlebarTool data ({ id, label, icon, active?, onSelect? }).

Title-bar slots are permanent mount points: a component you register there stays mounted while the user moves between the chat and full pages (Capabilities, Messaging, Artifacts, contributed routes), so a useEffect that injects global side effects (a <style> tag, html[data-*] attributes, a MutationObserver) runs its setup once per registration and its cleanup once at dispose — never mid-navigation.

Controls that belong to ONE page (the Kanban board switcher) go in WORKSPACE_PAGE_HEADER_AREA instead: it renders in the workspace panel's tab-header row while that page is on screen and is empty otherwise. Wrap the control in <WorkspacePageHeaderControl> (below) inside your page's own header row. In the workspace pane it projects into the page header; when the page is opened in a split route tile, which has no page header, it renders inline where you placed it. A raw <Contribute area={WORKSPACE_PAGE_HEADER_AREA}> only shows up in the workspace pane.

Palette commands and keybinds

import { PALETTE_AREA, KEYBINDS_AREA } from '@hermes/plugin-sdk'

ctx.registerMany([
  {
    id: 'open',
    area: PALETTE_AREA,
    data: {
      id: 'my-page.open',
      label: 'Open My Page',
      keywords: ['my', 'page'],
      run: () => host.navigate('/my-page')
    }
  },
  {
    id: 'refresh',
    area: KEYBINDS_AREA,
    data: {
      id: 'my-page.refresh',
      label: 'Refresh My Page',
      category: 'My Plugin',
      defaults: ['mod+shift+r'],
      run: () => void doRefresh()
    }
  }
])

Keybinds are user-rebindable in settings; defaults is just the initial binding.

Themes

A theme contribution ships a full DesktopTheme as its data (name, label, colors, …). It appears in the theme picker like a built-in.

import { THEMES_AREA } from '@hermes/plugin-sdk'

ctx.register({ id: 'noir', area: THEMES_AREA, data: myDesktopTheme })

Registering a theme lists it; it does not select it. useTheme() reads the painted appearance (theme, themeName, availableThemes, resolvedMode) and changes it (setTheme, setMode, previewTheme) from a component:

import { Button, useTheme } from '@hermes/plugin-sdk'

function ThemePicker() {
  const { availableThemes, setTheme, themeName } = useTheme()

  return availableThemes.map(t => (
    <Button key={t.name} disabled={t.name === themeName} onClick={() => setTheme(t.name)}>
      {t.label}
    </Button>
  ))
}

A switch driven by something other than a render — a gateway connecting, a socket event, any host.onEvent callback — has no component to hang the hook on. Use requestTheme(name) there. An unresolvable name is refused rather than coerced to the default skin, so the return value doubles as the availability check and a wrong name can never silently reset someone's appearance:

import { host, requestTheme } from '@hermes/plugin-sdk'

host.onEvent('gateway.ready', () => {
  if (!requestTheme('noir')) {
    host.notifyError('Connected, but the noir theme is not installed.')
  }
})

Both doors persist per profile, so a plugin-driven switch sticks exactly like a manual pick. To tint the active theme rather than replace it, use setAccentOverride(hex) and clear it in ctx.onDispose — the standalone Accent Picker plugin is the worked example (it is also a complete, installable disk plugin).

Composer extensions

COMPOSER_AREAS (top, bottom, underside, leading, actions, attachments, middleware) let a plugin add controls around the message composer, provide an attachment source, or transform a draft before it is sent (ComposerMiddleware with a handler(draft) => draft | null). top is a banner strip above the input and bottom a row below the input grid, both inside the composer chrome; underside is the floating strip BELOW the whole composer with no chrome of its own — the seat for a suggestion pill or a status hint that should sit outside the input frame (the next-prompt plugin renders its "next prompt" pill there).

Composer draft API — read and write the live input

For everything the composer areas can't do — put text INTO the input, replace what's there, read the current draft, or send it — use host.composer. This is the supported door; reaching for the ProseMirror DOM, [data-composer-target] lookups, or synthetic InputEvents is out of the plugin surface (catalog rule 8) and breaks the moment the app's markup moves. Addressing: null = the composer the user is typing in; a session id (stored or runtime) = that session's composer, in the primary pane or a tile; 'new' = the fresh draft that has no session id yet.

import { host } from '@hermes/plugin-sdk'

// Append to the active composer (modes: 'block' | 'inline' | 'prefix';
// 'prefix' seats a slash command at the start). Acknowledged like setDraft:
// true when a mounted surface applied the text, false when the text is blank
// or no live surface answers for the address.
const inserted = await host.composer.insertText(null, 'draft note', { mode: 'inline' })

// Replace a session's whole draft — '@'-ref and '/command' tokens hydrate
// into chips exactly like an official paste. False when no mounted surface
// answers (an unmounted session is never half-written).
const ok = await host.composer.setDraft('sess-1', 'plan:\n- @file:src/app.ts')

// Read the live draft: the mounted surface's in-DOM text (unsaved keystrokes
// included), falling back to the debounced persisted stash. Null when nothing
// holds it.
const text = await host.composer.getDraft('sess-1')

// Send as if the user typed + pressed Enter. Fail-closed like the app's own
// panels: no visible surface for the address → false, never a broadcast.
const sent = host.composer.submit('sess-1', 'ship it')

// Put the caret in a composer (same addressing). insertText/setDraft already
// focus a visible surface they paint; use this to return the caret after a
// plugin popover closes or from a "go to input" keybind.
host.composer.focus(null)
host.composer: {
  getDraft(sessionId: string | null): Promise<string | null>
  setDraft(sessionId: string | null, text: string): Promise<boolean>
  insertText(sessionId: string | null, text: string, opts?: { mode?: 'block' | 'inline' | 'prefix' }): Promise<boolean>
  submit(sessionId: string | null, text: string): boolean
  focus(sessionId: string | null): void
}

Arbitration. Every verb is fail-closed on its address: a request is answered only by the mounted composer that owns that session (its tile, or the primary pane when it shows that session); null is answered only by the surface the app's focus bus currently routes to; 'new' only by the primary pane while it shows no session — it never falls through to the active composer. No exact surface → null/false, never a broadcast into whichever pane happens to be mounted. Writes go through the app's own paint path, so @-ref / /-command tokens hydrate as chips and the result is byte-for-byte what the user would get by pasting. These are discrete, user-triggered actions with the same authority as typing — no plugin "owns" the draft afterwards, so there is nothing to tear down on disable; a plugin that wants a persistent presence around the input uses a COMPOSER_AREAS slot instead.

A multi-session plugin keeps its per-session state on its side (which session its panel is editing) and passes that id here; the bus guarantees one plugin write can never land in another session's composer.

Migrating off DOM reach-in (the held catalog plugins that motivated this API):

Plugin Was Now
next-prompt (#120660) window.dispatchEvent(new CustomEvent('hermes:composer-insert', …)) + a setTimeout hermes:composer-focus; [data-composer-target]/[data-pane-hidden] scan for the visible target await host.composer.insertText(null, suggestion.text, { mode: 'block' }), then host.composer.focus(null) if the pill lost the caret
prompt-snippets (#116030) same hermes:composer-insert event; [data-slot="composer-input"]/ProseMirror textContent + synthetic InputEvent fallback; surfaceEditorEl().focus() host.composer.insertText(sid, text, { mode: 'block' }); setDraft(sid, (await getDraft(sid) ?? '') + '\n' + text) replaces the fallback; host.composer.focus(sid) — sid = host.state.focusedSessionId.get()
prompt-enhancer (#116031) walks the editor's child nodes to serialize, rebuilds chip DOM, replaceChildren + synthetic InputEvent const draft = await host.composer.getDraft(sid) → transform → await host.composer.setDraft(sid, enhanced) (chips hydrate app-side); revert is another setDraft
memory-review (#115966) host.request('slash.exec', { session_id, command }) for /memory … — already SDK-only optional: host.composer.insertText(sid, '/memory pending', { mode: 'prefix' }) to seat the command for the user instead of executing it
intelligent-tool-break (#115964) "Message" button only toasts "type /break" (no composer write) host.composer.setDraft(host.state.focusedSessionId.get(), '/break ') then host.composer.focus(null) restores the intended behaviour

sessionId in the table is the id the plugin's UI is bound to; for a composer slot render it is host.state.focusedSessionId.get().

Session rows — decorations + the session list API

SESSION_ROW_AREAS (leading, trailing) let a plugin decorate sidebar session rows. Register a data contribution whose render({ sessionId }) returns a small element (a badge, a swatch, a tag) or null for rows you don't own — registering costs nothing on every other row:

import { SESSION_ROW_AREAS, type SessionRowSlotContribution } from '@hermes/plugin-sdk'

ctx.register({
  area: SESSION_ROW_AREAS.trailing,
  id: 'my-tag',
  data: {
    render: ({ sessionId }) => (owned.has(sessionId) ? <span className="my-tag">★</span> : null)
  } satisfies SessionRowSlotContribution
})

Pair it with the session list API, which writes the same stores the app's own controls write (so a plugin action and a hand click can never disagree):

host.sessions.pin(storedSessionId: string, pinned?: boolean, index?: number): void
host.sessions.reorder(storedSessionIds: string[]): void   // Recents; [] = clear manual order → default sort
host.sessions.reorderPinned(storedSessionIds: string[]): void  // Pinned section; omitted pins keep their slot
host.sessions.setColor(storedSessionId: string, color: string | null): void

Ids are STORED session ids: a live id is resolved to its durable lineage root, so pins and colours survive compression's id rotation — and the row-decoration slots hand your render that same durable id (_lineage_root_id ?? id), never the live one. Resolution goes through the rows this window has loaded; an id that matches no loaded row is written as given, so pass the slot's durable id (not a live id you remembered) for a session that may have scrolled out of the list. reorder accepts the same ids and maps each to its row's live id internally — the Recents order store is keyed by the live id, like the drag path. pin(id, true, index) slots the pin at that position in the Pinned list (a drop target between two pins); without index it appends, like the row's ⇧-click.

Arbitration. The verbs are discrete user-triggered edits of user data — last write wins, exactly as if the user had clicked, and no plugin owns the result afterwards. Slot contributions are ALL mounted (registration order, not first-wins), each inside its own error boundary: a plugin that throws or returns null for a row cannot suppress another plugin's decoration on it, and two decorations on one row render side by side. Core keeps the row's layout, gestures and title — slots augment, never replace.

Teardown. The verbs need none. Slot contributions are removed by the ctx.register disposer (disable/reload drops them and the row re-renders without the decoration).

Migration for the held catalog plugins:

  • drag-to-pin-session — replace the __reactFiber$* walk for onTogglePin / onReorderSessions / session._lineage_root_id with the row's slot id (render: ({ sessionId }) => … under SESSION_ROW_AREAS.leading gives you the durable id per row), then host.sessions.pin(sessionId, true, dropIndex) for a drop into the Pinned section, host.sessions.pin(sessionId, false) for a drop back into Recents, host.sessions.reorderPinned(ids) for a drag within the Pinned section, and host.sessions.reorder([]) for its "reset manual order" path.
  • better-session-appearance — replace the localStorage hermes.desktop.sessionColors write and the fiber-harvested onChange with host.sessions.setColor(sessionId, hex) (null clears), and render its per-row glyph through SESSION_ROW_AREAS.leading instead of mutating the row's status dot (the durable id it needed from _lineage_root_id is the slot's sessionId).

COMPOSER_AREAS.modelPill overrides the model pill's label — a provider ({ label: (ctx: ComposerModelPillContext) => string | null }) receives { model, reasoningEffort, compact } and returns the text to show, or null to let the next provider (then the core label) win. The pill keeps its chrome, pin dot, and menu; only the label changes — the sanctioned replacement for the MutationObserver text-rewriting plugins do today.

Model pill label providers

import { COMPOSER_AREAS, type ComposerModelPillContext, type ComposerModelPillProvider } from '@hermes/plugin-sdk'

interface ComposerModelPillContext {
  model: string            // the model slug the pill would show
  reasoningEffort: string  // the session's live effort level, '' when the model has none
  compact: boolean         // floating-composer mode: chevron only, providers are NOT consulted
}
interface ComposerModelPillProvider {
  label: (ctx: ComposerModelPillContext) => string | null
}

ctx.register({
  area: COMPOSER_AREAS.modelPill,
  id: 'my-label',
  data: { label: ({ model, reasoningEffort }) => reasoningEffort ? `${model} · ${reasoningEffort}` : null } satisfies ComposerModelPillProvider
})

Arbitration. Providers are consulted in registry order and the first non-empty string wins; later providers are not called. Anything else declines and the next provider is asked: null, '', a whitespace-only string, and any non-string value (an object, array or number is never rendered — the label is placed straight into JSX). A provider that throws also declines — the error is swallowed and the pill falls through to the next provider, then to the core label, so a broken plugin can never blank the pill. reasoningEffort is always a string ('' when the model has no effort level, never undefined). In compact (floating) mode the pill renders only the chevron and no provider is called. label() is re-evaluated only when the registry, the model, the effort level or the compact flag changes.

Teardown. The provider is an ordinary data contribution: ctx.register returns its disposer and the loader drops it when the plugin is disabled or reloaded, at which point the core label is restored. There is nothing to undo in ctx.onDispose.

Migrating compact-reasoning-label. The plugin used to find the pill via [data-slot="composer-root"] button span.truncate, regex-strip a trailing effort word from span.textContent, and re-run that sweep from a body-wide MutationObserver plus a 1 s setInterval. On current builds the core label no longer contains the effort word (the level has its own ReasoningPill), so the strip is a no-op; the sanctioned shape is to compute the label from the context instead of editing rendered text:

register(ctx) {
  ctx.register({
    area: COMPOSER_AREAS.modelPill,
    id: 'compact-reasoning-label',
    // Decline (null) whenever there is nothing to change so the core label wins.
    data: { label: ({ model }) => shorten(model) ?? null }
  })
  // No MutationObserver, no setInterval, no injected <style>: the contribution is
  // disposed with the plugin.
}

The reasoning-pill visibility CSS the plugin also injected has no hook; it is only needed if the app ever hides that label at narrow widths.

Appearance settings

APPEARANCE_AREAS.extra renders contributions at the end of Settings → Appearance, after the built-in sections. It is the seam for a plugin that used to inject nodes into that page or drive its widgets through React internals.

APPEARANCE_AREAS = { extra: 'appearance.extra' } as const

ctx.register({
  area: APPEARANCE_AREAS.extra,
  id: 'session-colour-rules',          // unique within your plugin
  render: () => <MyAppearanceCard />   // any React tree; SDK hooks allowed
})

Arbitration: every registration mounts, in registry order, each inside its own error boundary — a contribution that throws collapses to an inline error card naming its id (with Retry) and the rest of the page (and other plugins' cards) keep rendering. The slot mounts on the top-level Appearance page only, not on deep-link subpages (settings/appearance/<section>), and there is no "first wins" — plugins cannot suppress each other here.

Teardown: the registration is owned by the plugin loader; disabling or reloading the plugin disposes it and the card disappears on the next render. Nothing persists app-side, so there is nothing to clean up in ctx.onDispose.

For colour picking use the app's own swatch grid — ColorSwatches (already an SDK export) renders exactly what the profile rail and project dialog render, with your own onChange; feed it PROFILE_SWATCHES and pair it with host.sessions.setColor(id, color) for session colours.

Migrations for the plugins that motivated this slot:

  • better-session-appearance — replace the fiber walk that harvests the Appearance submenu's { onChange, swatches } and the clearBtn.after(...) / host.appendChild(panel) injection into the app dropdown with one ctx.register({ area: APPEARANCE_AREAS.extra, id: 'rules', render }) whose card renders <ColorSwatches swatches={PROFILE_SWATCHES} value onChange /> plus its bold/glyph/auto-rule controls; drop the data-better-session-appearance attribute writes and the dropdown max-height overrides.
  • hermes-appearance-hub — mount its paper-texture / font / intro-copy controls as an APPEARANCE_AREAS.extra card instead of a status-bar menu that reaches into Settings; the settings values still go through host.settings (allowlisted keys) and THEMES_AREA.

Embedding external content

Use the SDK's <SandboxedFrame src title /> for any external web content (reader views, dashboards, docs). It renders a sandboxed iframe with the app's guest-content posture: opaque origin, allow-scripts by default, no-referrer, lazy loading. Never mount a raw Electron <webview>: it lands on the app's persist: preview partition, sharing the app's cookies and storage.

interface SandboxedFrameProps {
  src: string      // absolute http(s): or data: URL; any other scheme renders nothing (console.warn)
  title: string    // required — an untitled frame is unlabelled in the a11y tree
  sandbox?: string // extra tokens; filtered through the allowlist below
  className?: string; style?: CSSProperties
  onLoad?, onError?: ReactEventHandler<HTMLIFrameElement>
  ref?: Ref<HTMLIFrameElement>
}

The props are an explicit allowlist, not ComponentProps<'iframe'>: allow (Permissions-Policy delegation — would hand a third-party site the mic/camera grant the app holds), srcdoc, name, allowFullScreen, csp, credentialless and every other iframe attribute are not props and nothing is spread onto the element, so they cannot reach the DOM even through a cast.

Arbitration (allowlist, not blocklist): the only tokens a caller may add are allow-scripts, allow-forms, allow-downloads, allow-pointer-lock, allow-orientation-lock, allow-presentation. Everything else — allow-same-origin, allow-top-navigation*, allow-popups*, allow-modals, allow-storage-access-by-user-activation, and any token the primitive does not know — is dropped case-insensitively even if passed; an emptied set falls back to the default posture (allow-scripts), because a frame with no sandbox attribute is fully privileged. loading="lazy" and referrerPolicy="no-referrer" are not props. The opaque origin IS the containment: guest content cannot reach the app, its storage, or the preload bridge.

Teardown: it is a plain React element — unmounting your pane/page removes the frame and its realm; nothing is registered app-side.

Migration for rss-reader (#115972): replace the stubbed /preview → 501 → host.openWorkspace('rss-browser') → empty RssBrowserFrame → ctx.os.openExternal chain with <SandboxedFrame src={article.url} title={article.title} /> inside the workspace page; drop the leftover .rss-browser-frame-host webview CSS.

Transcript directives — inline components the model addresses

TRANSCRIPT_DIRECTIVE_AREA makes the transcript itself a contribution area. Register a named directive and the agent can render your component inline in an assistant message by emitting a paragraph of the form ::name{key="value"}:

import { TRANSCRIPT_DIRECTIVE_AREA } from '@hermes/plugin-sdk'

ctx.register({
  id: 'task-card',
  area: TRANSCRIPT_DIRECTIVE_AREA,
  data: {
    name: 'task', // the model writes ::task{id="BB-12"}
    render: ({ attrs, streaming }) => jsx(TaskCard, { taskId: attrs.id, streaming })
  }
})

Rules the host enforces so the surface stays safe:

  • The directive must be the entire paragraph — ::name mid-prose stays prose, so plugin components can never hijack running text.
  • Attributes are untrusted model output (key="value" pairs, string-only). Validate your own fields; render nothing on garbage rather than guessing.
  • An unclaimed directive (no plugin registered for the name) renders as the plain paragraph it always was — nothing breaks when a plugin is off.
  • Renders are wrapped in the contribution error boundary: a throw degrades to an inline error chip, never a dead message.
  • First registration wins on a name collision; namespace adventurous names with your slug (myplugin-board, not board).

Core ships one directive as the reference consumer: ::preview{file="…"} renders the workspace HTML file live inside the message — a sandboxed srcdoc iframe with an opaque origin (scripts run and the widget is fully interactive; no reach into the app, its storage, or the bridge). The frame sizes itself to the content (height live, width adopted from the content's intrinsic span, flush left in the message flow), and a theme prelude hands the document the app's resolved tokens (--foreground, --muted-foreground, --accent, --border, --card), the app font, and a transparent background — so widget-shaped HTML reads as native while a full page keeps its own design. Non-HTML targets and remote gateways fall back to the classic preview card. Tell the agent about your directive in a skill (that's how it learns to emit it).

Previewed widgets can also talk back. Inside the frame, window.hermes.send('get-price eth') (or a declarative <button data-hermes-send="get-price eth"> — no script needed) hands that prompt to the agent as a user turn, off-screen: no bubble takes up the transcript, the widget updating is the visible response. The turn is still real — it wakes the agent, rides the composer's steer/queue rules, and persists (typed hidden) so resume and the session DB keep the full record. Prompts are trimmed, capped at 500 chars, and throttled to one per second per frame.

Mount-scoped chrome (Contribute)

ctx.register is for permanent contributions. When chrome should live and die with a component that's already on screen (a page's own header control leaves when the page unmounts), render <Contribute> inside it instead:

import { Contribute, WORKSPACE_PAGE_HEADER_AREA } from '@hermes/plugin-sdk'

jsx(Contribute, {
  area: WORKSPACE_PAGE_HEADER_AREA,
  id: 'my-page:switcher', // namespace with your slug
  children: jsx(MySwitcher, {})
})

It registers on mount and disposes on unmount automatically.

For a page-header control, use WorkspacePageHeaderControl instead. It picks the placement from where the page renders: in the workspace pane it contributes to WORKSPACE_PAGE_HEADER_AREA, and anywhere else (a split route tile) it renders its children in place. Put it where the control should sit when inline:

import { WorkspacePageHeaderControl } from '@hermes/plugin-sdk'

jsx(WorkspacePageHeaderControl, {
  id: 'my-page:switcher', // namespace with your slug
  children: jsx(MySwitcher, {})
})

WorkspacePageHeaderControl is new in this release. Older desktop builds don't export it, and a named import of a missing SDK export stops the plugin module from loading. A plugin that must also run on older builds either feature-detects through a namespace import (import * as sdk from '@hermes/plugin-sdk', then sdk.WorkspacePageHeaderControl ?? …) or keeps the raw Contribute form above.

Sidebar nav visibility and order (SIDEBAR_NAV_PREFS_AREA)

A plugin hides or re-orders the sidebar's top nav rows by contributing a preference, not by writing a setting. Core merges every sidebarNav.prefs contribution at render and applies the result to the rows it would otherwise show; the default list itself never changes.

import { SIDEBAR_NAV_PREFS_AREA, type SidebarNavPrefsContribution } from '@hermes/plugin-sdk'

// Payload (`data`) of a sidebarNav.prefs contribution
interface SidebarNavPrefsContribution {
  hide?: string[]   // rows to drop
  order?: string[]  // rows to place first, in this order
}

ctx.register({
  id: 'prefs',
  area: SIDEBAR_NAV_PREFS_AREA,
  data: { hide: ['cron'], order: ['capabilities', 'new-session'] } satisfies SidebarNavPrefsContribution
})

Nav ids are the rows' own ids. Core rows: new-session, capabilities, messaging, artifacts, cron (the SidebarNavId type; artifacts and cron only render in Advanced mode). A contributed row's id is its registered SIDEBAR_NAV_AREA id, which ctx.register namespaces to ${pluginId}:${id} — a plugin that registered { id: 'kanban-nav', area: SIDEBAR_NAV_AREA } as kanban names that row 'kanban:kanban-nav' in hide/order.

Arbitration. Hidden rows are the union of every contribution's hide (no plugin can un-hide another's row; hide beats order), except capabilities: the row hosting the Plugins tab is the user's path to a plugin's own off-switch, so it can be moved but never hidden. Contributions apply in the registry's area order — lowest order, then registration — and the first one's order wins: later contributions place only ids not yet placed, rows no order names keep their default relative order after the named ones. Unknown ids are inert.

Teardown. The contribution lives in the registry, so disabling or reloading the plugin disposes it and the rows come straight back — nothing to clear. This is why it is not a host.sidebar.hide() verb: host is a singleton that cannot attribute a write, a persisted preference would outlive the plugin, and two plugins would overwrite each other's order.

Persisting the user's choice is the plugin's job, in its own ctx.storage: read the saved prefs on register, contribute them, and on every edit save + dispose + re-contribute (re-registering the same id replaces it).

// sidebar-manager: replaces `[data-sbm-off] { display:none }` + re-parenting <li>s
let dispose = () => {}
const apply = (prefs: SidebarNavPrefsContribution) => {
  dispose()
  dispose = ctx.register({ id: 'prefs', area: SIDEBAR_NAV_PREFS_AREA, data: prefs })
}
apply(ctx.storage.get('navPrefs', {}))
// in the editor's onChange:
ctx.storage.set('navPrefs', next); apply(next)

Session sections (Pinned, Recents, Cron jobs) are not covered — nav rows only.

Host API

Everything on host is reachable from anywhere in a plugin. State atoms are readonly — read with .get() in handlers, subscribe with useValue(atom) in components.

host.state.activeSessionId  // ReadableAtom<string | null>
host.state.awaitingResponse // ReadableAtom<boolean>  true until the first assistant payload
host.state.busy             // ReadableAtom<boolean>  focused chat is working after a send
host.state.busyBySession    // ReadableAtom<Record<string, boolean>>  runtime id → mid-turn
host.state.focusedSessionId // ReadableAtom<string | null>  (runtime id of the FOCUSED session — tile-aware; prefer for session.* RPC)
host.state.focusedSessionProfile // ReadableAtom<string>  (owner profile of the focused chat — prefer over `profile` for per-bot/profile readouts)
host.state.focusedStoredSessionId // ReadableAtom<string | null>  (durable id — navigation / session-list matching)
host.state.focusedUsage     // ReadableAtom<UsageStats | null>  (live streamed usage of the focused session, no RPC needed)
host.state.cwd              // ReadableAtom<string>
host.state.gateway          // ReadableAtom<string>  socket state ('idle' | 'connecting' | 'open' | …)
host.state.model            // ReadableAtom<string>
host.state.profile          // ReadableAtom<string>
host.state.viewport         // ReadableAtom<{ width, height, narrow }>

host.state.gateway is the WebSocket connection, not whether a chat turn is running. A session can be mid-turn while the socket is open; another session can be idle at the same time. Disable composer or plugin actions from the focused session's turn-busy (host.state.busyBySession[sessionId], or that session's view.$busy) — never from gateway, and never from a process-global busy flag.

host.notify({ kind, message, title?, detail?, action? })  // toast; returns id
host.notifyError(error, fallbackMessage)                   // toast an error
ctx.os.notify({ title, body?, silent?, icon?, activate?, onActivate?, actions? })
                                           // native OS notification (attributed to your plugin)
ctx.os.openExternal(url)                   // OS default handler (browser, mail, spotify:) → Promise<boolean>
ctx.os.revealPath(path)                    // reveal in Finder / Explorer → Promise<boolean>
ctx.os.writeClipboard(text)                // system clipboard → Promise<boolean>
host.navigate('/route')                    // hash-route navigation
host.openSession(id, { profile?, intent? }) // open a stored session core-style;
                                           //   profile: soft-swap to that profile's backend first
                                           //   intent: 'in-place' (default) | 'stack' | 'tab' | 'window'
host.newChat(profile?)                     // fresh chat draft, optionally in another profile
host.openWorkspace(id, { render, title?, minWidth?, onClose? })
                                           // dock a plugin-rendered tab into the MAIN
                                           //   workspace zone and reveal it; returns a disposer
host.paneVisibility(paneId)                // ReadableAtom<boolean> — is a contributed pane
                                           //   actually on screen (its zone's active tab)?
host.onEvent(type, fn)                     // gateway event stream ('*' = all); returns disposer.
                                           //   Calls made during register() are retired with the
                                           //   plugin; elsewhere prefer ctx.onEvent (always tracked)
host.logs(...)                             // tail an app log file
host.status()                              // one-shot system status snapshot
host.restartGateway()                      // restart the backend gateway
host.profileRoutes()                       // [{ profile, targetProfile, connectionId, mode }]
host.requestProfile<T>(route, method, params?, timeoutMs?, { spawnPriority? })   // registry-routed RPC; no foreground swap
host.requestProfile<T>(profile, method, params?) // legacy v1/local overload
host.request<T>(method, params?)           // active-gateway JSON-RPC — the real power
host.sessions.pin(storedSessionId, pinned?, index?)  // pin/unpin (default pinned=true); index = slot in Pinned;
                                           //   same store the row's ⇧-click / drop writes
host.sessions.reorder(ids)                 // replace the manual Recents order (what a drag persists); [] resets
host.sessions.reorderPinned(ids)           // permute the Pinned section (the pinned drag path)
host.sessions.setColor(storedSessionId, color | null)  // per-session colour override; null clears
host.skills.list(profile?)                 // every skill for the scope (Capabilities endpoints)
host.skills.setEnabled(name, on, profile?)  // enable/disable a skill — the Capabilities toggle
host.toolsets.list(profile?)               // toolsets + enabled state
host.toolsets.setEnabled(name, on, profile?)// enable/disable a toolset
host.profiles.list(scope?: ProfileScope)   // the profile list the profile rail reads
host.pluginDecisions                       // READ-ONLY atom: this window's plugin on/off decisions (frozen copies)
host.i18n.registerAppLocale(id, { endonym?, rtl?, translations? })  // add a whole UI language (language pack); returns disposer
host.i18n.languageOptions()                // [{ id, endonym, rtl, source }] — what the language switcher lists

host.request is the same JSON-RPC the app itself uses (sessions, config, skills, cron, kanban, …). host.requestProfile accepts a descriptor from host.profileRoutes() and routes that RPC through its exact registry source and profile without changing the active chat or gateway. The profile-only overload is retained only for the sole-local/legacy topology; registry-aware plugins should pass the descriptor so two sources exposing the same profile name cannot collide.

A call that may cold-start a pooled profile backend dials at background priority by default, and background dials never get the slot the pool keeps free for user actions. When the call IS a user action (a save, a button press, a dialog opening), pass host.requestProfile(route, method, params, undefined, { spawnPriority: 'foreground' }); otherwise, with the pool full of warm backends, it waits out the 30-second slot timeout and fails. Keep the background default for polling and roster warming.

host.openWorkspace(id, { render, title?, minWidth?, onClose? }) docks a plugin-rendered view into the main workspace zone — the same center area session tiles and previews use — as a tab, and reveals it. Re-calling it with the same id refreshes the content in place and re-fronts the tab instead of opening a duplicate. Closing the tab (the tab's Close control or ⌘W) tears the registration down and fires your onClose; the returned disposer closes it programmatically. Feature-detect it (typeof host.openWorkspace === 'function') and fall back to a regular contributed pane on older desktop builds — Bot Mode's group-chat rooms are the reference consumer (main-window takeover when available, in-panel view otherwise).

host.paneVisibility(paneId) returns a readonly reactive atom that is true while a contributed pane is actually on screen: present in the layout tree, not dismissed or hidden, its zone un-minimized, and holding its zone's active tab slot (a lone pane in its own zone counts). The id is the contribution-scoped pane id, <pluginId>:<paneId>. Atoms are memoized per id, so calling it in render is safe. Use it to register companion UI only while your pane is visible — Bot Mode's Cronjobs pane is the reference consumer: it registers while the Bots pane holds the sidebar tab and unregisters when the user tabs back to Sessions. Feature-detect on older desktops (typeof host.paneVisibility === 'function') and fall back to always-registered behavior.

host.profileRoutes() inventories every registered source in the current connection registry. Connect-on-demand SSH sources expose a credential-free default seed route without opening a tunnel, so a plugin can be the first caller that dials them; an SSH remoteProfile remains the route's backend targetProfile. connectionId is the registry routing identity; pair it with profile for keys and persistence. Endpoint, token, SSH host/key, and other raw connection fields never cross the plugin IPC boundary. profile is the source-local route used for requests; targetProfile is the backend Hermes profile served by that route. They differ when a route explicitly maps to another backend profile (for example an SSH remoteProfile override or a legacy per-profile URL alias). This distinction preserves backend identity without exposing connection secrets.

Profile-shaped plugins get first-class methods too: profiles.list (each profile + its most recent conversation as last_session; pass include_sessions: false to skip the per-profile DB probe; pass preferred_session_ids: { profileName: sessionId } for an exact, existence-checked lookup of one pinned session per profile — each named row gains a preferred_session summary that resolves hidden rows and compression lineages to their live tip, or null when the id is definitively gone; older gateways ignore the param and omit the field) and profiles.create (name, description, clone_from, clone_all, no_skills, soul, optional model + provider pin) — the ws twins of the dashboard's /api/profiles REST routes. host.state.busy is the focused chat's live turn (thinking and streaming). host.state.awaitingResponse stays true from send until the first assistant payload. Both follow the chat the user is actually looking at — the focused session tile when one holds focus, else the primary workspace chat (the same signal the statusbar's busy pulse reads). Subscribe in a component:

const busy = useValue(host.state.busy)

For token-level detail, listen with host.onEvent (message.start, message.delta, message.complete).

host.onEvent streams live gateway events (message deltas, session lifecycle, tool activity). Listeners are isolated — a throw in your listener can't affect app dispatch. Every host door is async-safe: a sync throw from an internal helper (e.g. no desktop bridge in a plain browser) becomes a rejection your .catch() sees, never an error-boundary crash.

ctx.os is the curated OS door — every way a plugin reaches outside the app window, in one namespace attributed to your plugin. ctx.os.notify posts a native OS notification — the same Electron pipeline the app's own approval/turn alerts use. It fires only while the user is away from Hermes (backgrounded / unfocused); use host.notify for the in-app toast when they're looking at the app. Users can silence it per device under Settings ▸ Notifications ▸ "Plugin notifications", and repeats from the same plugin are throttled, so treat it as a signal for genuinely notable events — not a log.

Rich presentation + activation (extends the original ctx.os door):

ctx.os.notify({
  title: 'New match found',
  body: 'Someone matched your signal',
  icon: '/abs/path/to/icon.png', // Electron Notification icon
  // Body click → focus Hermes + navigate. Same vocabulary as OS deep links:
  activate: 'hermes://index-network/intent/1',
  // or: activate: '/index-network/intent/1'
  // or: activate: { path: '/index-network/intent/1' }
  onActivate: () => focusLocalState('1'), // optional renderer callback
  actions: [
    { id: 'open', label: 'Open', activate: 'hermes://index-network/intent/1' },
    { id: 'dismiss', label: 'Dismiss', onAction: () => dismiss('1') },
  ],
})

activate is deeplink-compatible: hermes://index-network/intent/1 and the hash path /index-network/intent/1 resolve to the same in-app route (and the same hermes://… URL works as an OS deep link). Action buttons only render on signed macOS builds; elsewhere the body click still activates. Navigation only happens on user click — never from a background event alone.

The other doors (openExternal, revealPath, writeClipboard) resolve false instead of throwing when the capability isn't available (older desktop shell, plain browser) — branch on the result rather than sniffing the bridge.

Desktop appearance settings — host.settings

host.settings is the supported door for the small set of Desktop-local appearance preferences plugins may share with the native Settings page. Every key is bound to the store atom + setter the Settings page itself uses, so a plugin write is exactly a user click on that control: it takes effect at once, persists through the preference's existing storage schema, and the last write wins (no plugin "owns" the value afterwards, nothing to tear down for set).

type DesktopSettingValues = {
  'backdrop.v1': boolean
  'composerPopout.gesturesEnabled': boolean
  'intro-splash.v1': boolean
  'reasoning.collapsedByDefault': boolean
  sessionListDensity: 'compact' | 'comfortable' | 'detailed'
  tabStripDefault: 'auto' | 'always' | 'never'
}
host.settings.get<K extends DesktopSettingKey>(key: K): DesktopSettingValues[K]
host.settings.set<K extends DesktopSettingKey>(key: K, value: DesktopSettingValues[K]): void
host.settings.subscribe<K extends DesktopSettingKey>(key: K, fn: (value: DesktopSettingValues[K]) => void): () => void
register(ctx) {
  host.settings.set('sessionListDensity', 'detailed')

  // subscribe emits the current value now, then after every native or plugin write.
  const dispose = host.settings.subscribe('backdrop.v1', enabled => { /* … */ })
  // Teardown rule: `host` is a module singleton and cannot tell which plugin
  // subscribed, so YOU retire the listener — otherwise it outlives a disable/reload.
  ctx.onDispose(dispose)
}

Arbitration: the allowlist above is closed. An unknown key or a value outside the key's type throws synchronously (Unsupported desktop setting: … / Invalid value for desktop setting: …) and nothing is written — host.settings never touches localStorage directly, so it cannot bypass a store's schema or migration. Feature-detect host.settings when supporting older Desktop builds.

Deliberately not keys, and why:

Wanted Use instead Why not a raw key
keybind map (hermes.desktop.keybinds) KEYBINDS_AREA contribution a raw map write rebinds every other plugin's shortcuts; the area merges per plugin and is torn down with it
active theme / mode record THEMES_AREA (register a theme; the user selects it) theme selection is per window/profile and arbitrated by the app, not a flat preference
pluginDecisions (desktop plugin on/off) the app's Plugins tab (a read-only view is a separate SDK hook) a plugin toggling another plugin's enable state is plugins interfering with each other
toolView.technical, embed-mode, titlebarAppActions, translucency.v2, user-bubble-transparency.v1, hermesDesktop.zoom.* follow-up keys after each store is audited some drive the main process or window chrome; each needs its own guard and ownership review before it becomes plugin-writable

Migration — hermes-appearance-hub, which today does localStorage.setItem('hermes.desktop.sessionListDensity', id) followed by window.dispatchEvent(new StorageEvent('storage', …)) to wake the app's store (readSimpleKey/writeSimpleKey, readBoolKey/writeBoolKey):

// before
localStorage.setItem('hermes.desktop.backdrop.v1', String(on))
window.dispatchEvent(new StorageEvent('storage', { key: 'hermes.desktop.backdrop.v1', newValue: String(on) }))
// after — the store notifies its own subscribers; no synthetic StorageEvent
host.settings.set('backdrop.v1', on)
host.settings.set('sessionListDensity', id)          // was hermes.desktop.sessionListDensity
host.settings.set('tabStripDefault', id)             // was hermes.desktop.tabStripDefault
host.settings.set('reasoning.collapsedByDefault', on) // was hermes.desktop.reasoning.collapsedByDefault
host.settings.set('composerPopout.gesturesEnabled', on)
host.settings.set('intro-splash.v1', mode !== 'off') // replaces clicking #setting-field-appearance.intro-splash

Reads become host.settings.get(key); its MutationObserver on the Settings page's intro-splash switch becomes host.settings.subscribe('intro-splash.v1', fn) (disposer → ctx.onDispose). prompt-snippets reads localStorage.getItem('hermes.desktop.keybinds') to back up its shortcut — that is the keybind-map row above: contribute the default through KEYBINDS_AREA and keep the user's override in ctx.storage, not in the app's map.

Typed capabilities bridge — host.skills, host.toolsets, host.profiles, host.pluginDecisions

type ProfileScope = undefined | null | string | { connectionId?: null | string; profile?: null | string }

host.skills.list(profile?: ProfileScope): Promise<SkillInfo[]>
host.skills.setEnabled(name: string, enabled: boolean, profile?: ProfileScope): Promise<{ ok: boolean; name: string; enabled: boolean }>
host.toolsets.list(profile?: ProfileScope): Promise<ToolsetInfo[]>
host.toolsets.setEnabled(name: string, enabled: boolean, profile?: ProfileScope): Promise<{ ok: boolean; name: string; enabled: boolean }>
host.profiles.list(scope?: ProfileScope): Promise<{ profiles: ProfileInfo[] }>
host.pluginDecisions: ReadableAtom<Record<string, boolean>>   // get() / subscribe() / listen() — no set()

These wrap the same api/* module functions the Capabilities page calls (GET /api/skills, PUT /api/skills/toggle, GET /api/tools/toolsets, PUT /api/tools/toolsets/<name>, GET /api/profiles) with the page's profile scoping. Omit profile to act on the app-wide active profile; pass a name or a { connectionId, profile } route to configure another profile without swapping the foreground one. Nothing new is arbitrated: every call is already reachable through host.request — the value is typing plus profile scoping, so stop calling window.hermesDesktop.api raw.

host.pluginDecisions mirrors the app's plugin enable/disable map (plugin id → true/false; an absent id means the user never chose and the plugin's own defaultEnabled applies). It is read-only by design: a set() would let one plugin flip another plugin's enable state — exactly "plugins messing with each other's functionality" — and host is a module singleton that cannot tell which plugin is calling to restrict a writer to the caller's own id. The object has no set at runtime, not just in the types, and every value it hands out (get(), .value, the argument to subscribe/listen callbacks) is a frozen copy — assigning into it throws instead of leaking into the map the app reads and persists. Enabling/disabling plugins stays in the app's Plugins tab; link to it with host.navigate('/capabilities?tab=plugins').

Teardown: the verbs are discrete user-triggered actions that write the same backend state the page writes, so nothing is owned afterwards and there is nothing to tear down. A subscribe() on host.pluginDecisions returns its disposer — register it with ctx.onDispose so a disabled or reloaded plugin stops listening.

Migration (better-capabilities):

// before                                                  // after
desktopApi({ path: '/api/skills' })                        host.skills.list()
desktopApi({ path: '/api/skills/toggle', method: 'PUT',    host.skills.setEnabled(name, enabled)
  body: { name, enabled } })
desktopApi({ path: '/api/tools/toolsets' })                host.toolsets.list()
desktopApi({ path: `/api/tools/toolsets/${name}`,          host.toolsets.setEnabled(name, enabled)
  method: 'PUT', body: { enabled } })
desktopApi({ path: '/api/profiles' })                      host.profiles.list()
JSON.parse(localStorage.getItem(                           host.pluginDecisions.get()
  'hermes.desktop.pluginDecisions.v2'))                    ctx.onDispose(host.pluginDecisions.subscribe(fn))
localStorage.setItem('hermes.desktop.pluginDecisions.v2')  // declined — host.navigate('/capabilities?tab=plugins')
row.querySelector('[data-slot="switch"]').click()          // same: the app's Plugins tab owns the toggle

Language packs — host.i18n.registerAppLocale / ctx.i18n.registerAppLocale

ctx.i18n.register localizes YOUR plugin's strings. A language pack does the opposite: it adds (or extends) a language for the WHOLE app — every core label, dialog and tip — so a Polish user sees a Polish desktop. Registration is a partial catalog merged over the bundled catalog for that id (or English for a new language); anything the pack leaves out falls back per key, never to a raw key. The switcher lists the language by its endonym at once (no flags — languages are not countries), <html dir> follows rtl, and display.language stays whatever the user chose: registering is not selecting.

export default {
  id: 'hermes-lang-pl',
  register(ctx) {
    // Attributed to this plugin and dropped on unload/disable.
    ctx.i18n.registerAppLocale('pl', {
      endonym: 'Polski',
      englishName: 'Polish',        // search-only
      rtl: false,
      translations: {
        // Nested like en.ts…
        common: { save: 'Zapisz', cancel: 'Anuluj' },
        // …or flat dotted keys (what a .desktop.yaml pack flattens to).
        'catalog.results': '{0} wyników'
      }
    })
  }
}
host.i18n.registerAppLocale(id, { endonym?, englishName?, rtl?, translations? }): () => void
host.i18n.languageOptions(): LanguageOption[]   // bundled ∪ registered ∪ backend i18n.languages

Where English has a function entry (results: n => `${n} results` ), a pack gives a plain string with POSITIONAL placeholders — {0}, {1} in argument order — and the merge wraps it into the same call shape. The full key set is published in locales/_keys.desktop.json (regenerate with npm run i18n:keys in apps/desktop and commit it; CI pins the file to en.ts), which is what hermes plugins validate checks a pack's <lang>.desktop.yaml against.

host.i18n.registerAppLocale is the same call for code with no ctx in reach; it returns the disposer — hand it to ctx.onDispose. Prefer the ctx form.

A pack that also ships core (Python) and TUI strings needs no desktop code at all: declare provides_locales: [pl] in plugin.yaml with locales/pl.yaml, pl.tui.yaml, pl.desktop.yaml, and the gateway serves the desktop file over i18n.catalog {lang, surface: 'desktop'}; the app pulls it into the same registry (source backend) when display.language names it and re-pulls on a profile switch.

Data layer — React Query + nanostores

Plugins share the app's single QueryClient, so plugin queries cache, dedupe, poll, and invalidate exactly like core screens — never hand-roll a fetch loop.

import { useQuery, useMutation, useQueryClient, atom, computed, useValue } from '@hermes/plugin-sdk'

function MyPanel() {
  const { data, isLoading } = useQuery({
    queryKey: ['my-plugin', 'items'],
    queryFn: () => host.request('my.list', {})
  })
  // …
}

For state shared between a trigger and its panel (or a poll loop), use atom / computed — the same primitive host.state uses. Subscribe in the leaf that renders the value with useValue. To invalidate a query from outside React (e.g. a ctx.socket frame arriving), import the shared queryClient:

import { queryClient } from '@hermes/plugin-sdk'

ctx.socket('/events', () => {
  queryClient.invalidateQueries({ queryKey: ['my-plugin', 'items'] })
})

The UI kit and theming

Import the app's real components directly so your UI is native by default:

Button, Input, Textarea, Select*, Switch, Checkbox, SegmentedControl, Tabs*, Dialog*, ConfirmDialog, DropdownMenu*, ContextMenu*, Popover*, Tip/Tooltip*, Badge, Kbd/KbdGroup, SearchField, ScrollArea, Separator, Skeleton, GlyphSpinner, Loader, EmptyState, ErrorState, CopyButton, StatusDot, LogView, Codicon, DecodeText.

DecodeText's loop is opt-in as of this change — it decodes once and holds by default, so pass loop explicitly on progress surfaces that should keep scrambling.

Plus helpers: cn (class merge), icons.* (the app's lucide set), haptic, profileColor / profileColorSoft (deterministic identity colors), the time formatters relativeTime / fmtDateTime / fmtDayTime / coarseElapsed, useI18n (localized copy — your plugin stays translatable), and evaluateRuntimeReadiness.

Style with theme variables, never hardcoded colors. Panes already sit on the app's editor background — leave the background alone and use vars for everything else: var(--ui-text-secondary), var(--ui-text-tertiary), var(--ui-text-quaternary), var(--ui-stroke-secondary), var(--ui-accent). For canvas drawing, resolve them once with getComputedStyle(canvas).getPropertyValue('--ui-accent'). This is what makes a plugin reskin automatically with every theme.

A backend for your plugin

If your plugin needs server-side work, ship a Python plugin_api.py and reach it through ctx.rest / ctx.socket — a namespace scoped to your plugin by construction.

One package, both SDKs

A feature that needs a desktop UI and agent-side code (a Python plugin, its backend routes, skills) doesn't have to ship as two co-dependent installs. Put a desktop/plugin.js inside the agent package. When the package lands in any local plugins/ root (default home or a profile), the Electron main process copies that half into $HERMES_HOME/desktop-plugins/<id>/ beside a .hermes-package.json marker, and the renderer loads it through the exact same pipeline as the standalone disk door (hot reload included):

~/.hermes/plugins/<id>/           # ONE installable folder
├── plugin.yaml                   # the agent half: tools, hooks, commands
├── skills/…
├── dashboard/
│   ├── manifest.json             # { "name": "<id>", "api": "plugin_api.py" }
│   └── plugin_api.py             # backend routes → /api/plugins/<id>/
└── desktop/
    └── plugin.js                 # the desktop half: panes, commands, ctx.rest

The desktop/plugin.js half is an ordinary disk plugin — same contract, same imports, same ctx.rest('/…') reaching the plugin_api.py sitting beside it. Installing, sharing, or removing the feature is one folder: the app-root copy is refreshed when the source plugin.js changes (hermes plugins update, or Rescan) and removed when the package folder disappears. The copy is what makes the desktop half app-level: it exists once, however many profiles carry the package, and it never appears or disappears when the user switches the Capabilities profile selector. The renderer never scans plugins/ itself. The marker records the package name and its origin (catalog sidecar or git remote), which is what the Install here button on the Plugins page uses to install the agent half into another profile. The copy is staged beside the target and renamed into place, so an interrupted copy (a transient file lock, a crash mid-copy) never leaves a half-written folder behind; a leftover desktop-plugins/<id>/ that has no marker and no plugin.js is treated as such damage and replaced on the next Rescan, while a marker-less folder that does hold a plugin.js is a standalone plugin you installed by hand and is never overwritten.

Two enable switches still apply, on purpose, and both default to off: the desktop half ships opt-in — it inventories in Capabilities → Plugins but stays disabled until the user toggles it — matching the Python half's plugins.enabled gate in config.yaml (the security boundary below). Dropping a package into ~/.hermes/plugins is inert on every surface until the user says otherwise. The desktop half degrades gracefully when the backend half is off — ctx.rest returns errors, not crashes.

:::note The copy is local to the machine the desktop app runs on. Against a remote backend, the remote box's ~/.hermes/plugins is not reachable as a filesystem — only locally installed packages contribute a desktop half this way. For a remote backend the install dialog clones the desktop half separately into desktop-plugins/, the same as a desktop-only repo. A package whose agent half was installed on the remote host without that clone shows its Desktop half as unavailable (remote backend) on the Plugins page — not as a pending copy — and the tooltip points at Install from Git with the Desktop target checked. :::

Ship your plugin repo (agent half, desktop half, or both) and link to it with the hermes:// scheme — a plain anchor on your website or README:

<a href="hermes://plugin/install?repo=owner/repo&enable=1">Install in Hermes</a>

The user gets a confirmation dialog (repo id, source links, a probe of what the repo ships) and picks components before anything is installed — deep links never auto-install. force=1 replaces an existing install; dev builds use hermes-dev://. Full link reference: One-click install links.

The Python side

Desktop plugins reuse the dashboard plugin backend mount. Put the backend in a dashboard/ subfolder of a regular Hermes plugin and declare it in a manifest.json:

~/.hermes/plugins/<id>/
└── dashboard/
    ├── manifest.json      # { "name": "<id>", "api": "plugin_api.py" }
    └── plugin_api.py      # exports `router = APIRouter()`
# plugin_api.py
from fastapi import APIRouter

router = APIRouter()

@router.get("/board")
async def board():
    return {"items": ["one", "two", "three"]}

@router.post("/action")
async def action(body: dict):
    return {"ok": True, "received": body}

Routes mount under /api/plugins/<id>/ (GET /api/plugins/<id>/board, …). Backend code runs inside the gateway process, so it can import from the hermes-agent codebase directly (hermes_state, hermes_cli.config, …). See Extending the Dashboard → Backend API routes for the full backend reference — the mount is identical.

:::caution The Python backend is gated separately Enabling a plugin in the desktop Capabilities → Plugins panel is a renderer-side choice; it does not import Python. A user plugin's plugin_api.py is imported only when the plugin is in the plugins.enabled allow-list in config.yaml (and not in plugins.disabled). Project plugins (./.hermes/) never auto-import Python. This is a security boundary, not an oversight (GHSA-mcfc-hp25-cjv7). :::

Pushing events to your desktop half

Your backend runs inside the gateway process, so it can push an update to your own desktop half over the app's global event stream — the same stream host.onEvent subscribes to:

from hermes_cli.plugin_events import broadcast_plugin_event

broadcast_plugin_event("rss-reader", "feed.updated", {"count": 3})
# → event "plugin.rss-reader.feed.updated" reaches every connected desktop client
// register(ctx): the subscription is retired with the plugin
host.onEvent('plugin.rss-reader.feed.updated', ({ payload }) => refreshFeeds(payload))

broadcast_plugin_event(plugin_id, event, payload=None): the wire name is always plugin.<plugin_id>.<event>. plugin_id is your catalog name ([a-z0-9_-]{1,64}, no dots — it is the namespace and can't spell another plugin's); event is the BARE dotted name ("feed.updated", not "plugin.rss-reader.feed.updated"), segments of [A-Za-z0-9_-], so "", "../x" or "a..b" raise ValueError instead of stranding the desktop half on a name nobody emits. payload is a JSON dict (or omitted → {}), delivered as the event's payload; the frame carries session_id: "" like every global event. Delivery is fire-and-forget (a wedged client is skipped, never stalling your handler). Where it lands depends on the process the call runs in:

Caller runs in Reaches
hermes serve (the Desktop backend): plugin_api.py routers, plugin slash commands, tools and hooks in the agent turn every connected Desktop window
the dashboard.turn_isolation compute-host child (tools/hooks of an isolated turn) relayed over the host pipe to hermes serve, then every window
the stdio TUI (hermes in a terminal) that terminal's client
hermes gateway run (messaging platforms), hermes chat, cron, hermes plugins validate nobody — no Desktop client is attached to that process; the call is a logged no-op

Use this instead of importing tui_gateway.server internals; for plugin-scoped frames with a payload tailored per connection, ctx.socket('/events') remains the richer door.

Migration (rss-reader): drop the ~/.hermes/rss-reader/commands.jsonl queue, GET /commands and the 3 s ctx.rest('/commands') poll — the Python side calls broadcast_plugin_event('rss-reader', 'feed.updated', payload) where it used to enqueue, and the desktop side replaces the timer with host.onEvent('plugin.rss-reader.feed.updated', fn) inside register(ctx).

Calling it from the plugin

register(ctx) {
  // REST — namespace-relative path.
  const load = () => ctx.rest('/board')                 // GET /api/plugins/<id>/board
  const act  = () => ctx.rest('/action', { method: 'POST', body: { go: true } })

  // Live twin — a WebSocket to your own namespace.
  const stop = ctx.socket('/events', frame => {
    queryClient.invalidateQueries({ queryKey: [ctx.source, 'board'] })
  })
}

ctx.rest is profile-aware and rejects path traversal (..) so you can never address another plugin's API or a core route through it. PluginRestOptions is { method?, body?, upload?: { filename, contentType?, bytes }, timeoutMs? }.

ctx.socket auto-reconnects with backoff until disposed. It resolves to a no-op on OAuth remotes (single-use WS tickets are core-managed) — treat the socket as an accelerator over polling, never a replacement. Every consumer needs a polling fallback anyway, since any socket can drop.

For gateway-wide data (not your own namespace), use host.request (JSON-RPC) and host.onEvent (the gateway event stream) instead.

Settings, enable state, and storage

Every plugin — enabled or not — inventories in Capabilities → Plugins, where the user toggles it live (no app restart), reveals its folder, or rescans. The user's choice is remembered:

  • No choice yet → the plugin's own defaultEnabled (default true). Set defaultEnabled: false to ship an opt-in plugin that stays dark until the user flips it on.
  • Explicit choice → persisted and honored across restarts. A disabled plugin stays disabled — don't fight it; the user turned you off.

Persist your own state with ctx.storage, namespaced to your plugin (hermes.plugin.<id>.*) so plugins can't read or clobber each other:

ctx.storage.set('lastTab', 'board')
const tab = ctx.storage.get('lastTab', 'summary')
ctx.storage.remove('lastTab')

Bundled plugins

A plugin can ship in-tree at apps/desktop/src/plugins/<id>/plugin.tsx (default export a HermesPlugin). It's discovered by discoverBundledPlugins() at boot — no import, no registry edit — and shares the exact inventory + live enable/disable contract as a disk plugin. The two differences:

  1. It goes through the app's Vite build, so you can write real JSX and import the SDK by its @hermes/plugin-sdk alias.
  2. It's still lint-fenced to @hermes/plugin-sdk + react only — no @/… app internals.

No desktop plugins ship in the core tree today; the shipped app stays uncluttered and demos live in the hermes-example-plugins companion repo.

Security model

A loaded plugin is evaluated as ESM in the renderer realm with full app authority — the React singleton, the whole SDK (host.request gateway RPC, ctx.rest, storage, navigate) and the window.hermesDesktop native bridge (files, git, terminal, installs). The isolation the loader provides is error isolation only: a plugin can't crash the app (contributions are error-bounded, listeners isolated, a throwing register() is rolled back and reported on the plugin's row), but it can do anything the app can. Plugin storage namespaces are a convention, not a wall.

This is acceptable for local sources — a disk file can already run code on your machine — which is why the disk door only loads local files you (or your agent) wrote. For catalog installs the trust comes from admission — a human reviewed the exact pinned commit — backed by two tripwires: the desktop surface lint at admission and the loader's import allowlist (@hermes/plugin-sdk and react* only; a static or dynamic import of anything else, including https: URLs, fails the load). Neither is a sandbox. A future remote-source door will need a real boundary (iframe/worker + CSP + capability gating) before it can land; do not treat this pipeline as a trust boundary.

Pitfalls

  • JSX won't parse in a disk plugin. The file loads uncompiled — use jsx() / jsxs() (or React.createElement), not JSX syntax. (Bundled plugins are built, so JSX is fine there.)
  • Only three specifiers resolve: @hermes/plugin-sdk, react, react/jsx-runtime. Any other import surfaces an up-front load error.
  • Never hardcode colors (#000, black, rgb(...)). Leave the background alone; use theme variables (var(--ui-*)) for everything.
  • Reference only what you imported. A component you forgot to import (e.g. StatusDot) is a ReferenceError at render — double-check every identifier in your jsx() calls appears in the import line.
  • Read state imperatively in handlers ($atom.get()), never from a render closure — rapid events will otherwise see stale values. Subscribe (useValue) only in the leaf that renders the value.
  • Canvas panes must track their container with a ResizeObserver and resize the canvas (width/height attributes, not just CSS) — panes resize constantly.
  • Don't poll faster than a few seconds with host.request; prefer host.onEvent / ctx.socket and let React Query dedupe.
  • Bare globals are not tracked. window.setInterval, window.addEventListener, a <style> you append — the host never sees them, so they survive disable and every hot-reload (ES modules can't be unloaded; a hot-edit loop stacks live copies). Use ctx.setTimeout / ctx.setInterval / ctx.addEventListener, and wire anything else to ctx.onDispose. Module-scope state is yours to reset.
  • Module evaluation has a 10 s deadline. A top-level await that never settles (waiting for a gateway that isn't up) fails the load as import timed out instead of stalling the plugin scan; do the waiting inside register().
  • One id, one file. Two folders exporting the same id (a standalone install beside a unified-package copy) load first-wins in folder-name order; the later one shows duplicate id on its own row in Capabilities ▸ Plugins.
  • ctx.socket is a no-op on OAuth remotes. Always have a polling fallback.

Reference

SDK exports at a glance

Category Exports
Host host (.state.*, .settings, .notify, .notifyError, .navigate, .onEvent, .logs, .status, .restartGateway, .request, .composer, .sessions, .skills, .toolsets, .profiles, .pluginDecisions)
Plugin contract HermesPlugin, PluginContext, PluginContribution, PluginStorage, PluginOs, PluginRestOptions, PluginNativeNotificationInput, PluginNotificationAction, HermesOpenTarget, Contribution
Area constants PANES_AREA, ROUTES_AREA, SIDEBAR_NAV_AREA, STATUSBAR_AREAS, TITLEBAR_AREAS, WORKSPACE_PAGE_HEADER_AREA, PALETTE_AREA, KEYBINDS_AREA, THEMES_AREA, COMPOSER_AREAS, SESSION_ROW_AREAS, SIDEBAR_NAV_PREFS_AREA, APPEARANCE_AREAS
Area payloads RouteContribution, SidebarNavContribution, StatusbarItem, TitlebarTool, PaletteContribution, KeybindContribution, ComposerMiddleware, ComposerAttachmentProvider, SessionRowSlotContribution, SidebarNavPrefsContribution
React / state useValue, atom, computed, useQuery, useMutation, useQueryClient, queryClient, Contribute, WorkspacePageHeaderControl
Theming useTheme, requestTheme, setAccentOverride, $accentOverride, retintTheme, themeHue, DesktopTheme, DesktopThemeColors, plus OKLCH math (hexToOklch, oklchToHex, oklchToSrgb255, mixOklab, maxChroma, hueDelta, normalizeHex) and sRGB measures (contrastRatio — `number
UI kit Button, Input, Textarea, Select*, Switch, Checkbox, SegmentedControl, Tabs*, Dialog*, ConfirmDialog, DropdownMenu*, ContextMenu*, Popover*, Tip/Tooltip*, Badge, Kbd/KbdGroup, SearchField, ScrollArea, Separator, Skeleton, GlyphSpinner, Loader, EmptyState, ErrorState, CopyButton, StatusDot, LogView, Codicon, DecodeText, SandboxedFrame
Helpers cn, icons, haptic, useI18n, profileColor, profileColorSoft, relativeTime, fmtDateTime, fmtDayTime, coarseElapsed, evaluateRuntimeReadiness, catalogProviderMatches

The canonical, always-current export list is apps/desktop/src/sdk/index.ts.

Agents: the hermes-desktop-plugins skill

When an agent writes a desktop plugin, it should load the bundled hermes-desktop-plugins skill — it carries the same contract as this page in agent-facing form, with a ready-to-copy templates/plugin.js. This page is the human/developer reference; the skill is the working checklist.

Troubleshooting

My plugin doesn't appear. Confirm the file is at $HERMES_HOME/desktop-plugins/<id>/plugin.js and the folder name matches the export id. Run ⌘K → Reload desktop plugins. Check the app for an error toast naming the failure, and tail hermes logs gui -f.

"unsupported import" on load. A disk plugin may only import @hermes/plugin-sdk, react, and react/jsx-runtime. Remove any other import.

A jsx element renders nothing / throws ReferenceError. An identifier used in a jsx() call isn't imported. Add it to the import line.

ctx.rest returns 404. The backend isn't mounted: confirm ~/.hermes/plugins/<id>/dashboard/manifest.json has "api": "plugin_api.py", that the plugin is in plugins.enabled in config.yaml, and restart the gateway (backend routes mount at startup). Tail ~/.hermes/logs/errors.log for Failed to load plugin <id> API routes.

ctx.socket never fires. On an OAuth remote it's a no-op by design — use your polling fallback. Otherwise verify the backend exposes the matching @router.websocket(...) route under its namespace.

Colors look wrong after a theme switch. You hardcoded a color. Replace it with a var(--ui-*) theme variable.