Um arquivo, um painel: guia prático do Hermes Desktop Plugin SDK


Você passa o dia na janela do Hermes Desktop e, cedo ou tarde, surge um pequeno desejo: um painelzinho mostrando se os cron jobs de hoje realmente rodaram, ou um botão na barra de status que mostra os logs quando você clica nele.

Até recentemente esse desejo tinha duas respostas — viver sem ele, ou ir ler o código-fonte do app, que é uma base de código grande e pouco amigável a terceiros.

Agora existe uma terceira opção: escrever um arquivo, colocá-lo num diretório, e ele entra no ar alguns segundos depois. Cada superfície da janela do Hermes Desktop — painéis de layout, navegação lateral, a barra de status, ferramentas da barra de título, entradas da paleta ⌘K, atalhos de teclado, temas — se registra num único registro, e o próprio app principal registra sua interface exatamente como um plugin faz. Plugins não são um acessório colado: é assim que o app é construído.

Três minutos para o seu primeiro plugin

Aqui está o exemplo completo mais curto. Crie um arquivo:

// $HERMES_HOME/desktop-plugins/hello/plugin.js  (that's ~/.hermes/desktop-plugins/hello/plugin.js by default)
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) {
    // a 260px pane docked to the right
    ctx.register({
      id: 'pane',
      area: 'panes',
      title: 'hello',
      data: { placement: 'right', width: '260px' },
      render: () => jsx(HelloPane, {})
    })
    // a small button in the right side of the status bar
    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'
        })
    })
  }
}

Salve. O app observa desktop-plugins/, carrega o arquivo em poucos segundos e recarrega a cada salvamento posterior, no próprio lugar. Se nada aparecer, aperte ⌘K e escolha Reload desktop plugins. Se o carregamento falhar, você recebe um toast indicando o erro — corrija e salve de novo. Um plugin quebrado pode lançar exceções, mas não consegue derrubar o app.

O contrato: duas interfaces, e só

Um plugin exporta por padrão um único objeto:

interface HermesPlugin {
  id: string            // stable slug — the `plugin:<id>` source tag and the id namespace
  name?: string         // shown in Settings; defaults to id
  defaultEnabled?: boolean  // defaults to true; false ships it opt-in (inventoried, switched off)
  register: (ctx: PluginContext) => void
}

register recebe um contexto escopado. Ele nunca toca o registro diretamente — o contexto carimba a procedência (source: 'plugin:<id>') e coloca em namespace cada id de contribuição (<id>:<localId>), de modo que dois plugins nunca podem colidir.

interface PluginContext {
  readonly source: string                                    // e.g. 'plugin:hello'
  register: (c: PluginContribution) => () => void            // register one, returns a disposer
  registerMany: (cs: PluginContribution[]) => () => void      // register many at once
  rest: <T>(path, opts?) => Promise<T>                       // REST to your own /api/plugins/<id>
  socket: (path, onMessage) => () => void                    // live WebSocket in the same namespace
  os: PluginOs                                                // native notification, open-external, reveal, clipboard
  storage: PluginStorage                                      // plugin-scoped persistence (hermes.plugin.<id>.*)
}

Uma contribuição é a primitiva única que todas as superfícies compartilham:

interface Contribution {
  id: string            // your local id; the host namespaces it
  area: string          // WHERE it goes (see the area table below)
  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
}

Você fornece render, data, ou ambos, dependendo da área.

Onde as coisas podem entrar

Superfície area O que você fornece
Painel de layout (encaixável, arrastável) PANES_AREA ('panes') title + render + data (placement, dock, width, height)
Rota de página inteira ROUTES_AREA data (path) + render
Item de navegação da barra lateral SIDEBAR_NAV_AREA data (path, label, codicon)
Barra de status STATUSBAR_AREAS.left / .right render, ou data como um StatusbarItem
Barra de título TITLEBAR_AREAS.left / .center / .right data como um TitlebarTool, ou um <Contribute> escopado
Paleta ⌘K PALETTE_AREA data como um PaletteContribution
Atalho de teclado KEYBINDS_AREA data como um KeybindContribution
Tema THEMES_AREA data como um DesktopTheme
Composer COMPOSER_AREAS.* slots de render, ou middleware / provedores de anexo

O placement de um painel é o seu papel semântico (main, left, right, top, bottom): ele se empilha como abas junto com os painéis existentes daquele papel, e o usuário pode arrastá-lo para qualquer lugar depois. Para aterrissar numa borda específica em vez de empilhar, adicione um gesto dock — o mesmo que arrastar um painel sobre um chip de soltura com a mão.

Três coisas para saber antes de escrever mais

Um: não escreva sintaxe JSX. O arquivo em disco é carregado sem compilação, então um <div> nunca chega a um parser. Escreva a UI com chamadas jsx() / jsxs() de react/jsx-runtime (ou React.createElement), como no exemplo acima. É mais verboso, e é justamente isso que compra o “sem passo de build”.

Dois: exatamente três especificadores importáveis. @hermes/plugin-sdk, react e react/jsx-runtime — todo o resto falha ao resolver, de propósito. Uma cerca de lint impõe isso: importar internos do app (@/…) a partir de um plugin é um erro, e a correção correta é adicionar a capacidade ao SDK.

Três: plugins de desktop são do nível do app, não escopados por perfil. Existe um único diretório raiz, e ele vale para todos os perfis, gateways e máquinas remotas aos quais a janela se conecta. Se um plugin precisa se comportar de forma diferente por perfil, ele mesmo lê o perfil ativo.

Use a linguagem de design do próprio app

O ui.* do SDK entrega os componentes reais do app, variáveis de tema, ícones e formatadores. Isso importa mais do que parece: se você fizer seu próprio estilo na mão, o plugin parecerá estrangeiro numa janela onde todo o resto casa pixel a pixel. Use ui e as cores e espaçamentos acompanham o tema ativo — text-(--ui-text-tertiary) no exemplo é uma variável de tema que muda quando o usuário troca de tema.

Quer um backend? Você ganha um namespace

As chamadas rest e socket de um plugin vão para o seu próprio namespace, /api/plugins/<id>. Coloque um plugin_api.py no pacote do plugin e esse namespace ganha vida. (Só essa camada de backend é compartilhada entre os SDKs do desktop e do dashboard web — as APIs de front-end deles são totalmente separadas, então não as misture.) Isso dá ao plugin estado próprio dentro do processo, arquivos e conexões de gateway de longa duração sem enfiar lógica dentro de render. O React Query vem junto também, então busca, cache e invalidação não precisam ser reconstruídos.

Três modos de entrega

Modo Onde Quem Passo de build
Disco (recomendado) $HERMES_HOME/desktop-plugins/<id>/plugin.js usuários, agentes nenhum — ESM puro, carregado sem compilação
Pacote unificado $HERMES_HOME/plugins/<id>/desktop/plugin.js plugins que também entregam código do lado do agente nenhum — o mesmo pipeline de disco
Empacotado (bundled) apps/desktop/src/plugins/<id>/plugin.tsx dentro do repositório, entregue com o app sim — o build Vite do próprio app

Os três usam o mesmo contrato HermesPlugin, todos aparecem em Capabilities → Plugins, e todos ativam/desativam em tempo real. Para implementações de referência, o repositório companheiro oficial é hermes-example-plugins. O próprio Radio do app (streams ao vivo, busca de estações, controles de reprodução na barra de status, uma forma de onda reativa ao áudio) é entregue como um plugin empacotado só de SDK, desligado por padrão — ative-o em Capabilities → Plugins para ver até onde um plugin pode ir.

A fronteira de segurança: isto não é um sandbox

A documentação é incomumente direta sobre isso, e vale repassar literalmente: um plugin carregado é avaliado como ESM no realm do renderer com autoridade total do app — o singleton do React, todo o SDK (host.request gateway JSON-RPC, ctx.rest, storage, navigate). O isolamento aqui é isolamento de erro (fronteiras de contribuição, listeners isolados), o que significa que um plugin não consegue derrubar o app — mas ele pode fazer qualquer coisa que o app faz.

Para fontes locais isso é aceitável, porque um arquivo no seu disco já pode executar código. Mas é exatamente por isso que uma fonte remota (https + allowlist) não pode reutilizar esse pipeline como está: ela precisa de uma fronteira de verdade (iframe/worker mais CSP e controle de capacidades) antes de poder ser entregue. A opção { integrity } é a costura de transporte, não a costura de confiança.

Então: instale apenas plugins que você está disposto a executar na sua máquina. Se quiser saber o que o projeto está fazendo sobre procedência e revisão de plugins, leia o post do catálogo de plugins junto com este — o catálogo responde “de onde isso veio, foi revisado, estes são os bytes originais”.

Para onde ir agora

Uma nota honesta sobre o timing: este SDK é jovem. O Radio sendo entregue como um plugin de SDK empacotado e a reformulação da página Plugins aconteceram poucos dias antes deste post, o que significa que a superfície ainda pode mudar — e também que um plugin pequeno funcionando hoje provavelmente é algo que ninguém pensou em escrever ainda.