Documentação oficial desta página · v0.20.6
Estender o painel
O painel web do Hermes (hermes dashboard) foi feito para ser reestilizado e estendido sem fazer fork do código. Três camadas ficam expostas:
- Temas — arquivos YAML que repintam a paleta, a tipografia, o layout e o acabamento de cada componente do painel. Solte um arquivo em
~/.hermes/dashboard-themes/; ele aparece no seletor de temas. - Plugins de UI — um diretório com
manifest.json+ um bundle JavaScript que registra uma aba, substitui uma página embutida, complementa uma delas por slots com escopo de página, ou injeta componentes em slots nomeados do shell. - Plugins de backend — um arquivo Python dentro daquele diretório de plugin que expõe um
routerdo FastAPI; as rotas são montadas sob/api/plugins/<nome>/e chamadas a partir da UI do plugin.
As três são plug-and-play em tempo de execução: sem clonar o repositório, sem npm run build, sem aplicar patch no código do painel. Esta página é a referência canônica para as três.
Se você só quer usar o painel, veja Painel Web. Se você quer reestilizar a CLI de terminal (e não o painel web), veja Skins e Temas — o sistema de skins da CLI não tem relação com os temas do painel.
Esta página cobre o sistema de plugins do painel web (hermes dashboard) — window.__HERMES_PLUGIN_SDK__, um manifest.json e um bundle JS pré-construído. O app desktop nativo (hermes desktop) tem o próprio SDK, sem relação com este — @hermes/plugin-sdk, um único arquivo ESM, sem etapa de build — documentado em SDK de Plugin do Desktop. Só o namespace de backend do plugin_api.py (/api/plugins/<nome>) é compartilhado entre os dois.
Temas e plugins são independentes, mas se potencializam. Um tema pode viver sozinho (só um arquivo YAML). Um plugin pode viver sozinho (só uma aba). Juntos, permitem montar uma reestilização visual completa com HUDs personalizados — a demo strike-freedom-cockpit (que vive no repositório companheiro hermes-example-plugins — veja Demo combinando tema + plugin para os passos de instalação) faz exatamente isso.
Sumário
- Temas
- Plugins
- Início rápido — seu primeiro plugin
- Estrutura de diretórios
- Referência do manifesto
- O SDK de plugins
- Slots do shell
- Substituir páginas embutidas (
tab.override) - Complementar páginas embutidas (slots com escopo de página)
- Plugins só de slot (
tab.hidden) - Rotas de API no backend
- CSS próprio por plugin
- Descoberta e recarga de plugins
- Demo combinando tema + plugin
- Referência de API
- Resolução de problemas
Temas
Temas são arquivos YAML guardados em ~/.hermes/dashboard-themes/. O nome do arquivo não importa (o que o sistema usa é o campo name: do tema), mas a convenção é <nome>.yaml. Todo campo é opcional — chaves ausentes caem no tema embutido default, então um tema pode ser tão pequeno quanto uma única cor.
Início rápido — seu primeiro tema
mkdir -p ~/.hermes/dashboard-themes
# ~/.hermes/dashboard-themes/neon.yaml
name: neon
label: Neon
description: Magenta puro no preto
palette:
background: "#000000"
midground: "#ff00ff"
Recarregue o painel. Clique no ícone de paleta no cabeçalho e escolha Neon. O fundo fica preto, o texto e os destaques ficam magenta, e cada cor derivada (card, borda, apagado, ring etc.) é recalculada a partir dessa dupla de cores via color-mix() no CSS.
Essa é toda a entrada: um arquivo, duas cores. Tudo o que vem abaixo é refinamento opcional.
Paleta, tipografia, layout
Esses três blocos são o coração de um tema. Cada um é independente — sobrescreva um e deixe os outros.
Paleta (3 camadas)
A paleta é uma trinca de camadas de cor, mais uma cor de vinheta com brilho quente e um multiplicador de granulado. A cascata do sistema de design do painel deriva cada token compatível com shadcn (card, popover, muted, border, primary, destructive, ring etc.) dessa trinca, via color-mix() do CSS. Sobrescrever três cores repercute em toda a interface.
| Chave | Descrição |
|---|---|
palette.background | Cor mais profunda da tela — geralmente quase preta. Comanda o fundo da página e o preenchimento dos cards. |
palette.midground | Texto principal e destaque. A maior parte do acabamento da interface lê isso (texto de primeiro plano, contorno de botões, anéis de foco). |
palette.foreground | Realce da camada de cima. O tema padrão define isso como branco com alfa 0 (invisível); temas que queiram um destaque forte por cima podem aumentar o alfa. |
palette.warmGlow | String rgba(...) usada como cor de vinheta pelo <Backdrop />. |
palette.noiseOpacity | Multiplicador de 0 a 1,2 sobre a camada de granulado. Menor = mais suave, maior = mais áspero. |
Cada camada aceita ou {hex: "#RRGGBB", alpha: 0.0–1.0} ou uma string hex simples (o alfa cai para 1.0).
palette:
background:
hex: "#05091a"
alpha: 1.0
midground: "#d8f0ff" # hex simples, alpha = 1.0
foreground:
hex: "#ffffff"
alpha: 0 # camada de cima invisível
warmGlow: "rgba(255, 199, 55, 0.24)"
noiseOpacity: 0.7
Tipografia
| Chave | Tipo | Descrição |
|---|---|---|
fontSans | string | Pilha CSS de font-family para o texto corrido (aplicada em html, body). |
fontMono | string | Pilha CSS de font-family para blocos de código, <code> e utilitários .font-mono. |
fontDisplay | string | Pilha opcional para títulos/display. Cai para fontSans. |
fontUrl | string | URL opcional de folha de estilo externa. Injetada como <link rel="stylesheet"> no <head> ao trocar de tema. A mesma URL nunca é injetada duas vezes. Funciona com Google Fonts, Bunny Fonts, folhas @font-face auto-hospedadas — qualquer coisa linkável. |
baseSize | string | Tamanho de fonte raiz — controla a escala rem. Por exemplo, "14px", "16px". |
lineHeight | string | Altura de linha padrão. Por exemplo, "1.5", "1.65". |
letterSpacing | string | Espaçamento entre letras padrão. Por exemplo, "0", "0.01em", "-0.01em". |
typography:
fontSans: '"Orbitron", "Eurostile", "Impact", sans-serif'
fontMono: '"Share Tech Mono", ui-monospace, monospace'
fontDisplay: '"Orbitron", "Eurostile", sans-serif'
fontUrl: "https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;600;700&family=Share+Tech+Mono&display=swap"
baseSize: "14px"
lineHeight: "1.5"
letterSpacing: "0.04em"
Trocar a fonte pela interface (sem YAML)
O seletor de temas no cabeçalho do painel tem uma seção Font abaixo da
lista de temas. Escolha qualquer fonte ali e ela sobrescreve a fonte de texto
do tema ativo — a escolha é independente do tema e persiste entre trocas de
tema (guardada no config.yaml sob dashboard.font). Escolha
Theme default para limpar a sobrescrita e voltar ao fontSans do tema
ativo.
O seletor oferece um catálogo curado (pilhas do sistema mais um conjunto de
famílias do Google Fonts entre sans / serif / mono). Ele deliberadamente
não aceita uma URL de fonte em texto livre — a folha de estilo da fonte é
injetada como um <link>, então o catálogo mantém fixas as origens injetadas.
Para um tipo totalmente personalizado, defina fontSans + fontUrl no YAML do
tema, como mostrado acima. O fontMono do tema (blocos de código, terminal)
nunca é tocado pela sobrescrita da interface.
Layout
| Chave | Valores | Descrição |
|---|---|---|
radius | qualquer comprimento CSS ("0", "0.25rem", "0.5rem", "1rem", ...) | Token de raio de canto. Mapeia para --radius e cascateia em --radius-sm/md/lg/xl — todo elemento arredondado muda junto. |
density | compact | comfortable | spacious | Multiplicador de espaçamento aplicado como a variável CSS --spacing-mul. compact = 0,85×, comfortable = 1,0× (padrão), spacious = 1,2×. Escala o espaçamento base do Tailwind, então padding, gap e utilitários de espaçamento mudam proporcionalmente. |
layout:
radius: "0"
density: compact
Variantes de layout
layoutVariant escolhe o layout geral do shell. Cai para "standard" quando ausente.
| Variante | Comportamento |
|---|---|
standard | Coluna única, largura máxima de 1600px (padrão). |
cockpit | Trilho de barra lateral à esquerda (260px) + conteúdo principal. Preenchido por plugins pelo slot sidebar — veja Slots do shell. Sem um plugin, o trilho mostra um espaço reservado. |
tiled | Remove o limite de largura máxima, para as páginas usarem toda a largura da janela. |
layoutVariant: cockpit
A variante atual fica exposta em document.documentElement.dataset.layoutVariant, então CSS cru dentro de customCSS pode mirá-la via :root[data-layout-variant="cockpit"] ....
Ativos de tema (imagens como variáveis CSS)
Distribua URLs de arte junto com um tema. Cada slot nomeado vira uma variável CSS (--theme-asset-<nome>) que o shell embutido e qualquer plugin podem ler. O slot bg é ligado automaticamente ao pano de fundo; os outros slots são voltados a plugins.
assets:
bg: "https://exemplo.com/hero-bg.jpg" # ligado automaticamente ao <Backdrop />
hero: "/minhas-imagens/strike-freedom.png" # para barras laterais de plugin
crest: "/minhas-imagens/brasao.svg" # para plugins no header-left
logo: "/minhas-imagens/logo.png"
sidebar: "/minhas-imagens/trilho.png"
header: "/minhas-imagens/arte-de-cabecalho.png"
custom:
scanLines: "/minhas-imagens/scanlines.png" # → --theme-asset-custom-scanLines
Os valores aceitam:
- URLs simples — envolvidas em
url(...)automaticamente. - Expressões já prontas
url(...),linear-gradient(...),radial-gradient(...)— usadas como estão. "none"— recusa explícita.
Todo ativo também é emitido como --theme-asset-<nome>-raw (a URL sem o embrulho), caso um plugin precise passá-la para <img src> em vez de background-image.
Plugins leem isso com CSS ou JS comum:
// Dentro de um slot de plugin
const hero = getComputedStyle(document.documentElement)
.getPropertyValue("--theme-asset-hero").trim();
Ajustes de acabamento de componentes
componentStyles reestiliza componentes individuais do shell sem escrever seletores CSS. As entradas de cada balde viram variáveis CSS (--component-<balde>-<propriedade-em-kebab>) que os componentes compartilhados do shell leem. Assim, card: vale para todo <Card>, header: para a barra do app, e assim por diante.
componentStyles:
card:
clipPath: "polygon(12px 0, 100% 0, 100% calc(100% - 12px), calc(100% - 12px) 100%, 0 100%, 0 12px)"
background: "linear-gradient(180deg, rgba(10, 22, 52, 0.85), rgba(5, 9, 26, 0.92))"
boxShadow: "inset 0 0 0 1px rgba(64, 200, 255, 0.28)"
header:
background: "linear-gradient(180deg, rgba(16, 32, 72, 0.95), rgba(5, 9, 26, 0.9))"
tab:
clipPath: "polygon(6px 0, 100% 0, calc(100% - 6px) 100%, 0 100%)"
sidebar: {}
backdrop: {}
footer: {}
progress: {}
badge: {}
page: {}
Baldes suportados: card, header, footer, sidebar, tab, progress, badge, backdrop, page.
Os nomes de propriedade usam camelCase (clipPath) e são emitidos em kebab (clip-path). Os valores são strings CSS comuns — qualquer coisa que o CSS aceite (clip-path, border-image, background, box-shadow, animation, ...).
Ajustes de cor
A maioria dos temas não vai precisar disso — a paleta de 3 camadas deriva todo token do shadcn. Use colorOverrides quando quiser um destaque específico que a derivação não produz (um vermelho destrutivo mais suave para um tema pastel, um verde de sucesso específico de uma marca).
colorOverrides:
primary: "#ffce3a"
primaryForeground: "#05091a"
accent: "#3fd3ff"
ring: "#3fd3ff"
destructive: "#ff3a5e"
border: "rgba(64, 200, 255, 0.28)"
Chaves suportadas: card, cardForeground, popover, popoverForeground, primary, primaryForeground, secondary, secondaryForeground, muted, mutedForeground, accent, accentForeground, destructive, destructiveForeground, success, warning, border, input, ring.
Cada chave mapeia 1 para 1 na variável CSS --color-<kebab> (por exemplo, primaryForeground → --color-primary-foreground). Qualquer chave definida aqui vence a cascata da paleta, mas só para o tema ativo — trocar de tema limpa as sobrescritas.
customCSS cru
Para acabamento no nível de seletor que o componentStyles não consegue expressar — pseudoelementos, animações, media queries, sobrescritas com escopo de tema — coloque CSS cru em customCSS:
customCSS: |
/* Sobreposição de scanlines — só visível quando a variante cockpit está ativa. */
:root[data-layout-variant="cockpit"] body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 100;
background: repeating-linear-gradient(to bottom,
transparent 0px, transparent 2px,
rgba(64, 200, 255, 0.035) 3px, rgba(64, 200, 255, 0.035) 4px);
mix-blend-mode: screen;
}
O CSS é injetado como uma única tag <style data-hermes-theme-css> com escopo, na aplicação do tema, e é limpo na troca de tema. Limitado a 32 KiB por tema.
Temas embutidos
Cada tema embutido traz a própria paleta, tipografia e layout — trocar produz mudanças visíveis além da cor.
| Tema | Paleta | Tipografia | Layout |
|---|---|---|---|
Hermes Teal (default) | Verde-azulado escuro + creme | Pilha do sistema, 15px | Raio 0.5rem, comfortable |
Hermes Teal (Large) (default-large) | Igual à do padrão | Pilha do sistema, 18px, altura de linha 1.65 | Raio 0.5rem, spacious |
Midnight (midnight) | Azul-violeta profundo | Inter + JetBrains Mono, 14px | Raio 0.75rem, comfortable |
Ember (ember) | Carmesim quente + bronze | Spectral (serifada) + IBM Plex Mono, 15px | Raio 0.25rem, comfortable |
Mono (mono) | Tons de cinza | IBM Plex Sans + IBM Plex Mono, 13px | Raio 0, compact |
Cyberpunk (cyberpunk) | Verde neon no preto | Share Tech Mono em tudo, 14px | Raio 0, compact |
Rosé (rose) | Rosa + marfim | Fraunces (serifada) + DM Mono, 16px | Raio 1rem, spacious |
Temas que usam Google Fonts (todos, menos o Hermes Teal) carregam a folha de estilo sob demanda — na primeira vez que você troca para eles, uma tag <link> é injetada no <head>.
Referência completa do YAML de tema
Todos os botões em um só arquivo — copie e apare o que você não precisa:
# ~/.hermes/dashboard-themes/ocean.yaml
name: ocean
label: Ocean Deep
description: Azuis de mar profundo com destaques em coral
# paleta de 3 camadas (aceita {hex, alpha} ou hex simples)
palette:
background:
hex: "#0a1628"
alpha: 1.0
midground:
hex: "#a8d0ff"
alpha: 1.0
foreground:
hex: "#ffffff"
alpha: 0.0
warmGlow: "rgba(255, 107, 107, 0.35)"
noiseOpacity: 0.7
typography:
fontSans: "Poppins, system-ui, sans-serif"
fontMono: "Fira Code, ui-monospace, monospace"
fontDisplay: "Poppins, system-ui, sans-serif" # opcional
fontUrl: "https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600&family=Fira+Code:wght@400;500&display=swap"
baseSize: "15px"
lineHeight: "1.6"
letterSpacing: "-0.003em"
layout:
radius: "0.75rem"
density: comfortable
layoutVariant: standard # standard | cockpit | tiled
assets:
bg: "https://exemplo.com/ocean-bg.jpg"
hero: "/minhas-imagens/kraken.png"
crest: "/minhas-imagens/ancora.svg"
logo: "/minhas-imagens/logo.png"
custom:
pattern: "/minhas-imagens/ondas.svg"
componentStyles:
card:
boxShadow: "inset 0 0 0 1px rgba(168, 208, 255, 0.18)"
header:
background: "linear-gradient(180deg, rgba(10, 22, 40, 0.95), rgba(5, 9, 26, 0.9))"
colorOverrides:
destructive: "#ff6b6b"
ring: "#ff6b6b"
customCSS: |
/* Quaisquer ajustes adicionais no nível de seletor */
Recarregue o painel depois de criar o arquivo. Troque de tema ao vivo pela barra de cabeçalho — clique no ícone de paleta. A escolha persiste no config.yaml sob dashboard.theme e é restaurada ao recarregar.
Plugins
Um plugin de painel é um diretório com um manifest.json, um bundle JS pré-construído e, opcionalmente, um arquivo CSS e um arquivo Python com rotas do FastAPI. Plugins ficam ao lado dos outros plugins do Hermes em ~/.hermes/plugins/<nome>/ — a extensão de painel é uma subpasta dashboard/ dentro desse diretório de plugin, então um mesmo plugin pode estender a CLI/gateway e o painel a partir de uma única instalação.
Plugins não empacotam React nem componentes de UI. Eles usam o SDK de plugins, exposto em window.__HERMES_PLUGIN_SDK__. Isso mantém os bundles minúsculos (tipicamente alguns KB) e evita conflitos de versão.
Início rápido — seu primeiro plugin
Crie a estrutura de diretórios:
mkdir -p ~/.hermes/plugins/my-plugin/dashboard/dist
Escreva o manifesto:
// ~/.hermes/plugins/my-plugin/dashboard/manifest.json
{
"name": "my-plugin",
"label": "My Plugin",
"icon": "Sparkles",
"version": "1.0.0",
"tab": {
"path": "/my-plugin",
"position": "after:skills"
},
"entry": "dist/index.js"
}
Escreva o bundle JS (uma IIFE simples — sem etapa de build):
// ~/.hermes/plugins/my-plugin/dashboard/dist/index.js
(function () {
"use strict";
const SDK = window.__HERMES_PLUGIN_SDK__;
const { React } = SDK;
const { Card, CardHeader, CardTitle, CardContent } = SDK.components;
function MyPage() {
return React.createElement(Card, null,
React.createElement(CardHeader, null,
React.createElement(CardTitle, null, "My Plugin"),
),
React.createElement(CardContent, null,
React.createElement("p", { className: "text-sm text-muted-foreground" },
"Olá da minha aba personalizada no painel.",
),
),
);
}
window.__HERMES_PLUGINS__.register("my-plugin", MyPage);
})();
Recarregue o painel — sua aba aparece na barra de navegação, depois de Skills.
Se você prefere JSX, use qualquer bundler (esbuild, Vite, rollup) com o React como externo e saída em IIFE. A única exigência dura é que o arquivo final seja um único arquivo JS carregável via <script>. O React nunca é empacotado; ele vem de SDK.React.
Estrutura de diretórios
~/.hermes/plugins/my-plugin/
├── plugin.yaml # opcional — manifesto de plugin de CLI/gateway já existente
├── __init__.py # opcional — hooks de CLI/gateway já existentes
└── dashboard/ # extensão de painel
├── manifest.json # obrigatório — configuração da aba, ícone, ponto de entrada
├── dist/
│ ├── index.js # obrigatório — bundle JS pré-construído (IIFE)
│ └── style.css # opcional — CSS próprio
└── plugin_api.py # opcional — rotas de API no backend (FastAPI)
Um único diretório de plugin pode carregar três extensões ortogonais:
plugin.yaml+__init__.py— plugin de CLI/gateway (veja a página de plugins).dashboard/manifest.json+dashboard/dist/index.js— plugin de UI do painel.dashboard/plugin_api.py— rotas de backend do painel.
Nenhuma delas é obrigatória; inclua só as camadas de que você precisa.
Referência do manifesto
{
"name": "my-plugin",
"label": "My Plugin",
"description": "O que este plugin faz",
"icon": "Sparkles",
"version": "1.0.0",
"tab": {
"path": "/my-plugin",
"position": "after:skills",
"override": "/",
"hidden": false
},
"slots": ["sidebar", "header-left"],
"entry": "dist/index.js",
"css": "dist/style.css",
"api": "plugin_api.py"
}
| Campo | Obrigatório | Descrição |
|---|---|---|
name | Sim | Identificador único do plugin. Minúsculas, hífens permitidos. Usado em URLs e no registro. |
label | Sim | Nome de exibição mostrado na aba de navegação. |
description | Não | Descrição curta (mostrada nas telas administrativas do painel). |
icon | Não | Nome de ícone do Lucide. Cai para Puzzle. Nomes desconhecidos caem para Puzzle. |
version | Não | String semver. Cai para 0.0.0. |
tab.path | Sim | Caminho de URL da aba (por exemplo, /my-plugin). |
tab.position | Não | Onde inserir a aba. "end" (padrão), "after:<caminho>" ou "before:<caminho>" — o valor depois dos dois-pontos é o segmento de caminho da aba alvo (sem barra inicial). Exemplos: "after:skills", "before:config". |
tab.override | Não | Defina como um caminho de rota embutida ("/", "/sessions", "/config", ...) para substituir aquela página em vez de adicionar uma aba nova. Veja Substituir páginas embutidas. |
tab.hidden | Não | Quando verdadeiro, registra o componente e os slots sem adicionar aba na navegação. Usado por plugins só de slot. Veja Plugins só de slot. |
slots | Não | Slots nomeados do shell que este plugin preenche. Só ajuda de documentação — o registro de verdade acontece no bundle JS, via registerSlot(). Listar os slots aqui deixa as telas de descoberta mais informativas. |
entry | Sim | Caminho do bundle JS em relação a dashboard/. Cai para dist/index.js. |
css | Não | Caminho de um arquivo CSS a ser injetado como tag <link>. |
api | Não | Caminho de um arquivo Python com rotas do FastAPI. Montado em /api/plugins/<nome>/. |
Ícones disponíveis
Plugins usam nomes de ícone do Lucide. O painel mapeia por nome — nomes desconhecidos caem silenciosamente para Puzzle.
Mapeados no momento: Activity, BarChart3, Clock, Code, Database, Eye, FileText, Globe, Heart, KeyRound, MessageSquare, Package, Puzzle, Settings, Shield, Sparkles, Star, Terminal, Wrench, Zap.
Precisa de outro ícone? Abra um PR no ICON_MAP de web/src/App.tsx — é uma mudança puramente aditiva.
O SDK de plugins
Tudo o que um plugin precisa está em window.__HERMES_PLUGIN_SDK__. Plugins nunca devem importar o React diretamente.
const SDK = window.__HERMES_PLUGIN_SDK__;
// React + hooks
SDK.React // a instância do React
SDK.hooks.useState
SDK.hooks.useEffect
SDK.hooks.useCallback
SDK.hooks.useMemo
SDK.hooks.useRef
SDK.hooks.useContext
SDK.hooks.createContext
// Componentes de UI (primitivos do shadcn/ui)
SDK.components.Card
SDK.components.CardHeader
SDK.components.CardTitle
SDK.components.CardContent
SDK.components.Badge
SDK.components.Button
SDK.components.Input
SDK.components.Label
SDK.components.Select
SDK.components.SelectOption
SDK.components.Separator
SDK.components.Tabs
SDK.components.TabsList
SDK.components.TabsTrigger
SDK.components.PluginSlot // renderiza um slot nomeado (útil para UIs de plugin aninhadas)
// Cliente da API do Hermes + fetch cru
SDK.api // cliente tipado — getStatus, getSessions, getConfig, ...
SDK.fetchJSON // fetch cru para endpoints próprios (rotas registradas por plugins)
// Utilitários
SDK.utils.cn // combinador de classes do Tailwind (clsx + twMerge)
SDK.utils.timeAgo // "5m ago" a partir de um timestamp unix
SDK.utils.isoTimeAgo // "5m ago" a partir de uma string ISO
// Hooks
SDK.useI18n // hook de i18n para plugins multilíngues
Chamar o backend do seu plugin
SDK.fetchJSON("/api/plugins/my-plugin/data")
.then((data) => console.log(data))
.catch((err) => console.error("API call failed:", err));
O fetchJSON injeta o token de autenticação da sessão, expõe erros como exceções lançadas e faz o parse do JSON automaticamente.
Chamar endpoints embutidos do Hermes
// Status do agente
SDK.api.getStatus().then((s) => console.log("Version:", s.version));
// Sessões recentes
SDK.api.getSessions(10).then((resp) => console.log(resp.sessions.length));
Veja Painel Web → API REST para a lista completa.
Slots do shell
Slots deixam um plugin injetar componentes em pontos nomeados do shell do app — a barra lateral do cockpit, o cabeçalho, o rodapé, uma camada de sobreposição — sem tomar uma aba inteira. Vários plugins podem preencher o mesmo slot; eles são renderizados empilhados, na ordem de registro.
Registre de dentro do bundle do plugin:
window.__HERMES_PLUGINS__.registerSlot("my-plugin", "sidebar", MySidebar);
window.__HERMES_PLUGINS__.registerSlot("my-plugin", "header-left", MyCrest);
Catálogo de slots
Slots de todo o shell (renderizam em qualquer parte do acabamento do app):
| Slot | Local |
|---|---|
backdrop | Dentro da pilha de camadas do <Backdrop />, acima da camada de ruído. |
header-left | Antes da marca Hermes na barra de cima. |
header-right | Antes dos seletores de tema/idioma na barra de cima. |
header-banner | Faixa de largura total abaixo da navegação. |
sidebar | Trilho da barra lateral do cockpit — só renderiza quando layoutVariant === "cockpit". |
pre-main | Acima do outlet de rota (dentro de <main>). |
post-main | Abaixo do outlet de rota (dentro de <main>). |
footer-left | Conteúdo da célula do rodapé (substitui o padrão). |
footer-right | Conteúdo da célula do rodapé (substitui o padrão). |
overlay | Camada de posição fixa acima de todo o resto. Útil para acabamentos (scanlines, vinhetas) que o customCSS não consegue sozinho. |
Slots com escopo de página (renderizam só na página embutida indicada — use-os para injetar widgets, cards ou barras de ferramentas em uma página existente sem sobrescrever a rota inteira):
| Slot | Onde renderiza |
|---|---|
sessions:top / sessions:bottom | Topo / base da página /sessions. |
analytics:top / analytics:bottom | Topo / base da página /analytics. |
logs:top / logs:bottom | Topo (acima da barra de filtros) / base (abaixo do visualizador de logs) de /logs. |
cron:top / cron:bottom | Topo / base da página /cron. |
skills:top / skills:bottom | Topo / base da página /skills. |
config:top / config:bottom | Topo / base da página /config. |
env:top / env:bottom | Topo / base da página /env (Keys). |
docs:top / docs:bottom | Topo (acima do iframe) / base de /docs. |
chat:top / chat:bottom | Topo / base de /chat (só ativo quando o chat embutido está habilitado). |
Exemplo — adicionar um card de banner ao topo da página Sessions:
function PinnedSessionsBanner() {
return React.createElement(Card, null,
React.createElement(CardContent, { className: "py-2 text-xs" },
"Nota fixada injetada pelo my-plugin"),
);
}
window.__HERMES_PLUGINS__.registerSlot("my-plugin", "sessions:top", PinnedSessionsBanner);
Combine slots com escopo de página com tab.hidden: true se o seu plugin só complementa páginas existentes e não precisa de aba própria na barra lateral.
O shell só renderiza <PluginSlot name="..." /> para os slots acima. Nomes adicionais são aceitos pelo registro para UIs de plugin aninhadas — um plugin pode expor os próprios slots via SDK.components.PluginSlot.
Re-registro e HMR
Se o mesmo par (plugin, slot) for registrado duas vezes, a chamada mais recente substitui a anterior — isso combina com o jeito que o HMR do React espera que remontagens de plugin se comportem.
Substituir páginas embutidas (tab.override)
Definir tab.override como o caminho de uma rota embutida faz o componente do plugin substituir aquela página em vez de adicionar uma aba nova. Útil quando um tema quer uma página inicial personalizada (/), mas quer manter o resto do painel intacto.
{
"name": "my-home",
"label": "Home",
"tab": {
"path": "/my-home",
"override": "/",
"position": "end"
},
"entry": "dist/index.js"
}
Com override definido:
- O componente original da página em
/é removido do roteador. - O seu plugin renderiza em
/no lugar dele. - Nenhuma aba de navegação é adicionada para
tab.path(a substituição é justamente o ponto).
Só um plugin pode sobrescrever um dado caminho. Se dois plugins reivindicarem a mesma substituição, o primeiro vence e o segundo é ignorado, com um aviso em modo de desenvolvimento.
Se você só precisa adicionar um card ou barra de ferramentas a uma página existente, sem tomá-la, use slots com escopo de página.
Complementar páginas embutidas (slots com escopo de página)
A substituição completa via tab.override é pesada — seu plugin passa a ser dono da página inteira, inclusive de qualquer atualização futura que a gente entregue nela. Na maioria das vezes você só quer adicionar um banner, um card ou uma barra de ferramentas a uma página existente. É para isso que servem os slots com escopo de página.
Toda página embutida expõe os slots <pagina>:top e <pagina>:bottom, renderizados no topo e na base da área de conteúdo dela. Seu plugin preenche um deles chamando registerSlot() — a página embutida continua funcionando normalmente, e o seu componente renderiza ao lado.
Slots disponíveis: sessions:*, analytics:*, logs:*, cron:*, skills:*, config:*, env:*, docs:*, chat:* (cada um com :top e :bottom). Veja o catálogo completo em Slots do shell → Catálogo de slots.
Exemplo mínimo — fixar um banner no topo da página Sessions:
// ~/.hermes/plugins/session-notes/dashboard/manifest.json
{
"name": "session-notes",
"label": "Session Notes",
"tab": { "path": "/session-notes", "hidden": true },
"slots": ["sessions:top"],
"entry": "dist/index.js"
}
// ~/.hermes/plugins/session-notes/dashboard/dist/index.js
(function () {
const SDK = window.__HERMES_PLUGIN_SDK__;
const { React } = SDK;
const { Card, CardContent } = SDK.components;
function Banner() {
return React.createElement(Card, null,
React.createElement(CardContent, { className: "py-2 text-xs" },
"Lembre de rotular as sessões importantes antes de arquivar."),
);
}
// Espaço reservado para a aba oculta.
window.__HERMES_PLUGINS__.register("session-notes", function () { return null; });
// O trabalho de verdade.
window.__HERMES_PLUGINS__.registerSlot("session-notes", "sessions:top", Banner);
})();
Pontos-chave:
tab.hidden: truemantém o plugin fora da barra lateral — ele não tem página própria.- O campo
slotsdo manifesto é só documentação. A ligação de verdade acontece no bundle JS, viaregisterSlot(). - Vários plugins podem reivindicar o mesmo slot com escopo de página. Eles renderizam empilhados, na ordem de registro.
- Pegada zero quando nenhum plugin se registra: a página embutida renderiza exatamente como antes.
Um plugin de referência (example-dashboard, em hermes-example-plugins) traz uma demo viva que injeta um banner em sessions:top — instale para ver o padrão de ponta a ponta.
Plugins só de slot (tab.hidden)
Quando tab.hidden: true, o plugin registra o componente dele (para visitas diretas por URL) e os slots, mas nunca adiciona uma aba à navegação. Usado por plugins que só existem para injetar em slots — um brasão no cabeçalho, um HUD na barra lateral, uma sobreposição.
{
"name": "header-crest",
"label": "Header Crest",
"tab": {
"path": "/header-crest",
"position": "end",
"hidden": true
},
"slots": ["header-left"],
"entry": "dist/index.js"
}
O bundle ainda chama register() com um componente de espaço reservado (boa prática, caso alguém acesse a URL diretamente) e depois registerSlot() para fazer o trabalho de verdade.
Rotas de API no backend
Plugins podem registrar rotas do FastAPI definindo api no manifesto. Crie o arquivo e exporte um router:
# ~/.hermes/plugins/my-plugin/dashboard/plugin_api.py
from fastapi import APIRouter
router = APIRouter()
@router.get("/data")
async def get_data():
return {"items": ["one", "two", "three"]}
@router.post("/action")
async def do_action(body: dict):
return {"ok": True, "received": body}
As rotas são montadas sob /api/plugins/<nome>/, então o exemplo acima vira:
GET /api/plugins/my-plugin/dataPOST /api/plugins/my-plugin/action
As rotas de API de plugin ficam atrás da barreira normal de autenticação do painel — requisições sem autenticação levam 401 antes de a rota do plugin rodar, e requisições às rotas de um plugin desabilitado são rejeitadas na hora. Ainda assim, não exponha o painel em uma interface pública com --host 0.0.0.0 se você roda plugins não confiáveis — uma sessão autenticada também alcança as rotas deles.
Acessar as entranhas do Hermes
Rotas de backend rodam dentro do processo do painel, então podem importar diretamente do código do hermes-agent:
from fastapi import APIRouter
from hermes_state import SessionDB
from hermes_cli.config import load_config
router = APIRouter()
@router.get("/session-count")
async def session_count():
db = SessionDB()
try:
count = len(db.list_sessions(limit=9999))
return {"count": count}
finally:
db.close()
@router.get("/config-snapshot")
async def config_snapshot():
cfg = load_config()
return {"model": cfg.get("model", {})}
CSS próprio por plugin
Se o seu plugin precisa de estilos além das classes do Tailwind e do style= inline, adicione um arquivo CSS e aponte para ele no manifesto:
{
"css": "dist/style.css"
}
O arquivo é injetado como uma tag <link> no carregamento do plugin. Use nomes de classe específicos para evitar conflitos com os estilos do painel, e use as variáveis CSS do painel para acompanhar o tema:
/* dist/style.css */
.my-plugin-chart {
border: 1px solid var(--color-border);
background: var(--color-card);
color: var(--color-card-foreground);
padding: 1rem;
}
.my-plugin-chart:hover {
border-color: var(--color-ring);
}
O painel expõe todo token do shadcn como --color-*, mais os extras de tema (--theme-asset-*, --component-<balde>-*, --radius, --spacing-mul). Use essas variáveis e o seu plugin se reestiliza automaticamente com o tema ativo.
Descoberta e recarga de plugins
O painel varre três diretórios em busca de dashboard/manifest.json:
| Prioridade | Diretório | Rótulo de origem |
|---|---|---|
| 1 (vence em conflito) | ~/.hermes/plugins/<nome>/dashboard/ | user |
| 2 | <repo>/plugins/memory/<nome>/dashboard/ | bundled |
| 2 | <repo>/plugins/<nome>/dashboard/ | bundled |
| 3 | ./.hermes/plugins/<nome>/dashboard/ | project — só quando HERMES_ENABLE_PROJECT_PLUGINS está definida |
Os resultados da descoberta ficam em cache por processo do painel. Depois de adicionar um plugin novo, faça uma destas coisas:
# Força uma nova varredura sem reiniciar
curl http://127.0.0.1:9119/api/dashboard/plugins/rescan
…ou reinicie o hermes dashboard.
Ciclo de vida do carregamento de plugins
- O painel carrega. O
main.tsxexpõe o SDK emwindow.__HERMES_PLUGIN_SDK__e o registro emwindow.__HERMES_PLUGINS__. - O
App.tsxchamausePlugins()→ buscaGET /api/dashboard/plugins. - Para cada manifesto: o
<link>de CSS é injetado (se declarado), e depois uma tag<script>carrega o bundle JS. - A IIFE do plugin roda e chama
window.__HERMES_PLUGINS__.register(name, Component)— e, opcionalmente,.registerSlot(name, slot, Component)para cada slot. - O painel resolve o componente registrado contra o manifesto, adiciona a aba à navegação (a menos que esteja
hidden) e monta o componente como uma rota.
Os plugins têm até 2 segundos depois que o script deles carrega para chamar register(). Passado esse tempo, o painel para de esperar e conclui a renderização inicial. Se um plugin se registrar depois, ele ainda aparece — a navegação é reativa.
Se o script de um plugin falhar ao carregar (404, erro de sintaxe, exceção durante a IIFE), o painel registra um aviso no console do navegador e segue sem ele.
Demo combinando tema + plugin
O plugin strike-freedom-cockpit (repositório companheiro hermes-example-plugins) é uma demo completa de reestilização. Ele junta um YAML de tema com um plugin só de slot para produzir um HUD em estilo de cabine, sem fazer fork do painel.
O que ele demonstra:
- Um tema completo usando paleta, tipografia,
fontUrl,layoutVariant: cockpit,assets,componentStyles(cantos de card chanfrados, fundos em gradiente),colorOverridesecustomCSS(sobreposição de scanlines). - Um plugin só de slot (
tab.hidden: true) que se registra em três slots:sidebar— um painel MS-STATUS com barras de telemetria ao vivo, alimentadas porSDK.api.getStatus().header-left— um brasão de facção que lê--theme-asset-crestdo tema ativo.footer-right— um slogan personalizado que substitui a linha padrão da organização.
- O plugin lê a arte fornecida pelo tema via variáveis CSS, então trocar de tema muda o hero/brasão sem mexer no código do plugin.
Instalação:
git clone https://github.com/NousResearch/hermes-example-plugins.git
# Tema
cp hermes-example-plugins/strike-freedom-cockpit/theme/strike-freedom.yaml \
~/.hermes/dashboard-themes/
# Plugin
cp -r hermes-example-plugins/strike-freedom-cockpit ~/.hermes/plugins/
Abra o painel e escolha Strike Freedom no seletor de temas. A barra lateral de cabine aparece, o brasão surge no cabeçalho, o slogan substitui o rodapé. Volte para Hermes Teal e o plugin continua instalado, mas invisível (o slot sidebar só renderiza sob a variante de layout cockpit).
Leia o código do plugin (strike-freedom-cockpit/dashboard/dist/index.js, no repositório companheiro) para ver como ele lê variáveis CSS, se protege contra painéis antigos sem suporte a slots, e registra três slots a partir de um único bundle.
Referência de API
Endpoints de tema
| Endpoint | Método | Descrição |
|---|---|---|
/api/dashboard/themes | GET | Lista os temas disponíveis + o nome do ativo. Os embutidos retornam {name, label, description}; temas do usuário também trazem um campo definition com o objeto de tema normalizado completo. |
/api/dashboard/theme | PUT | Define o tema ativo. Corpo: {"name": "midnight"}. Persiste no config.yaml sob dashboard.theme. |
Endpoints de plugin
| Endpoint | Método | Descrição |
|---|---|---|
/api/dashboard/plugins | GET | Lista os plugins descobertos (com os manifestos, menos os campos internos). |
/api/dashboard/plugins/rescan | GET | Força uma nova varredura dos diretórios de plugin sem reiniciar. |
/dashboard-plugins/<nome>/<caminho> | GET | Serve arquivos estáticos do diretório dashboard/ de um plugin. A travessia de caminho é bloqueada. |
/api/plugins/<nome>/* | * | Rotas de backend registradas por plugins. |
SDK em window
| Global | Tipo | Provedor |
|---|---|---|
window.__HERMES_PLUGIN_SDK__ | objeto | registry.ts — React, hooks, componentes de UI, cliente de API, utilitários. |
window.__HERMES_PLUGINS__.register(name, Component) | função | Registra o componente principal de um plugin. |
window.__HERMES_PLUGINS__.registerSlot(name, slot, Component) | função | Registra em um slot nomeado do shell. |
Resolução de problemas
Meu tema não aparece no seletor.
Confira se o arquivo está em ~/.hermes/dashboard-themes/ e termina em .yaml ou .yml. Recarregue a página. Rode curl http://127.0.0.1:9119/api/dashboard/themes — o seu tema deve estar na resposta. Se o YAML tem erro de parse, o painel registra em errors.log, dentro de ~/.hermes/logs/.
A aba do meu plugin não aparece.
- Confira se o manifesto está em
~/.hermes/plugins/<nome>/dashboard/manifest.json(repare no subdiretóriodashboard/). - Rode
curl http://127.0.0.1:9119/api/dashboard/plugins/rescanpara forçar uma nova descoberta. - Abra as ferramentas de desenvolvedor do navegador → Network — confirme que
manifest.json,index.jse qualquer CSS carregaram sem 404. - Abra as ferramentas de desenvolvedor → Console — procure erros durante a IIFE ou
window.__HERMES_PLUGINS__ is undefined(indica que o SDK não inicializou, geralmente por uma quebra de renderização do React antes disso). - Verifique se o seu bundle chama
window.__HERMES_PLUGINS__.register(...)com o mesmo nome demanifest.json:name.
Componentes registrados em slots não renderizam.
O slot sidebar só renderiza quando o tema ativo tem layoutVariant: cockpit. Os outros slots sempre renderizam. Se você está registrando em um slot e não aparece nada, coloque um console.log dentro do registerSlot para confirmar se o bundle do plugin chegou a rodar.
As rotas de backend do plugin retornam 404.
- Confirme que o manifesto tem
"api": "plugin_api.py"apontando para um arquivo existente dentro dedashboard/. - Reinicie o
hermes dashboard— rotas de API de plugin são montadas uma vez, na inicialização, e não na nova varredura. - Confira se o
plugin_api.pyexporta umrouter = APIRouter()no nível do módulo. Outros nomes de exportação não são reconhecidos. - Acompanhe o
~/.hermes/logs/errors.logatrás deFailed to load plugin <name> API routes— erros de importação são registrados ali.
A troca de tema derruba meus ajustes de cor.
colorOverrides têm escopo do tema ativo e são limpos na troca de tema — isso é de propósito. Se você quer ajustes que persistem, coloque-os no YAML do seu tema, não no seletor ao vivo.
O customCSS do tema é cortado.
O bloco customCSS é limitado a 32 KiB por tema. Divida folhas de estilo grandes entre vários temas, ou passe para um plugin que injete uma folha completa pelo campo css dele (sem limite de tamanho).
Quero distribuir um plugin no PyPI.
Plugins de painel são instalados por estrutura de diretório, não por entry point do pip. O caminho de distribuição mais limpo hoje é um repositório git que o usuário clona em ~/.hermes/plugins/. Um instalador baseado em pip para plugins de painel não está montado no momento.