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
- Para plugins do lado do agente (Python CLI / gateway), esse é um SDK diferente: veja plugins de backend de terminal e como entregar um provedor de modelo como plugin pip.
- Para ver como é a experiência de um plugin de desktop pronto, o post do Desktop Skills Hub cobre como navegar e instalar skills dentro do app.
- Ainda não configurou? Comece pelo guia de instalação — o app de desktop é a superfície mais fácil para escrever plugins.
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.