NousResearch/hermes-agent · error

Hermes desktop bridge unavailable

Error message

Hermes desktop bridge unavailable

What it means

Thrown by pluginRest() in apps/desktop/src/hermes.ts:299 when `window.hermesDesktop.api` is not present — i.e. the code is not running inside the Hermes desktop shell (or the preload bridge has not been injected yet). All desktop REST traffic goes through the Electron preload bridge; in a plain browser tab, a web preview, or before bridge initialization there is no `api` function to route through.

Source

Thrown at apps/desktop/src/hermes.ts:299

function pluginPathSuffix(caller: string, path: string): string {
  const suffix = path.startsWith('/') ? path : `/${path}`

  if (suffix.split(/[?#]/, 1)[0].split('/').includes('..')) {
    throw new Error(`${caller}: illegal path traversal in "${path}"`)
  }

  return suffix
}

/** The plugin REST door. Every call is scoped BY CONSTRUCTION to the plugin's
 *  own backend namespace — `path` is relative to `/api/plugins/<pluginId>`
 *  ('/board' → `/api/plugins/kanban/board`), so a plugin can't address another
 *  plugin's API or a core route through it. Profile-aware like every desktop
 *  REST call. Broader reach (core endpoints, another namespace) is the future
 *  declared-capability seam; today the namespace IS the boundary. */
export async function pluginRest<T>(pluginId: string, path: string, opts: PluginRestOptions = {}): Promise<T> {
  if (!window.hermesDesktop?.api) {
    throw new Error('Hermes desktop bridge unavailable')
  }

  const suffix = pluginPathSuffix('pluginRest', path)

  return window.hermesDesktop.api<T>({
    path: `/api/plugins/${pluginId}${suffix}`,
    method: opts.method,
    body: opts.body,
    upload: opts.upload,
    timeoutMs: opts.timeoutMs,
    ...profileScoped()
  })
}

/** The plugin WebSocket door — the live twin of `pluginRest`, scoped the same
 *  way: `path` is relative to `/api/plugins/<pluginId>` ('/events' → the
 *  plugin's own event stream). Token-mode backends auth via the same query
 *  credential the app's own sockets use; OAuth remotes resolve null (callers

View on GitHub (pinned to c896c09c42)

Solutions

  1. Guard the call: `if (!window.hermesDesktop?.api) { /* hide/disable the plugin REST feature */ }`.
  2. In tests, stub `window.hermesDesktop = { api: vi.fn() }` before invoking plugin code.
  3. If code must run in both environments, branch to the web app's HTTP client when the bridge is absent.
  4. Verify you are actually inside the desktop shell (window.hermesDesktop defined) before mounting plugin surfaces that need REST.

Example fix

// before
const board = await pluginRest('kanban', '/board')

// after
if (!window.hermesDesktop?.api) {
  setUnavailable('Plugin API requires the Hermes desktop shell')
  return
}
const board = await pluginRest('kanban', '/board')
Defensive patterns

Strategy: type-guard

Type guard

function hasDesktopApi(w: Window): w is Window & { hermesDesktop: { api: <T>(r: unknown) => Promise<T> } } {
  return typeof (w as any).hermesDesktop?.api === 'function'
}

Try / catch

if (!hasDesktopApi(window)) { setFeatureUnavailable('requires the Hermes desktop shell'); return }
try { await pluginRest(id, '/board') } catch (e) { notifyError(e) }

Prevention

When it happens

Trigger: Calling pluginRest() from a component rendered in the web dashboard or a normal browser (no hermesDesktop preload); calling during app startup before the preload script exposes the bridge; running plugin code in a context where the sandboxed renderer did not receive contextBridge injection.

Common situations: A runtime plugin or shared module executed in both desktop and web builds; a unit test (jsdom) with no window.hermesDesktop stub; opening the renderer's index.html directly outside Electron; a corrupted or disabled preload bundle after an update.

Related errors


AI-assisted analysis of NousResearch/hermes-agent@c896c09c42 (2026-08-14). Data as JSON: /api/errors/98df971f7438bc60. Report an issue: GitHub.