RuleStack

Configs

Stacks

Compare

Diff

RuleStack

Configs

Stacks

Compare

Diff

Read API

RuleStack

Configs

Stacks

Compare

Diff

Read API

Configs/AGENTS.md/NousResearch/hermes-agent

AGENTS.md

AGENTS.md
AGENTS.mdroot

Quality

84/100

Scores the file, not the repository.

Length

9,766 words

81 headings · 25 code blocks

Repository

225k

— · pushed 0 days ago

Last changed

3 days ago

First indexed 3 days ago.
NousResearch/hermes-agent/AGENTS.mdRawGitHub
1# Hermes Agent - Development Guide
2 
3Instructions for AI coding assistants and developers working on the hermes-agent codebase.
4 
5**Never give up on the right solution.**
6 
7## What Hermes Is
8 
9Hermes is a personal AI agent that runs the same agent core across a CLI, a
10messaging gateway (Telegram, Discord, Slack, and ~20 other platforms), a TUI,
11and an Electron desktop app. It learns across sessions (memory + skills),
12delegates to subagents, runs scheduled jobs, and drives a real terminal and
13browser. It is extended primarily through **plugins and skills**, not by
14growing the core.
15 
16Two properties shape almost every design decision and are the lens for
17reviewing any change:
18 
19- **Per-conversation prompt caching is sacred.** A long-lived conversation
20 reuses a cached prefix every turn. Anything that mutates past context,
21 swaps toolsets, or rebuilds the system prompt mid-conversation invalidates
22 that cache and multiplies the user's cost. We do not do it (the one
23 exception is context compression).
24- **The core is a narrow waist; capability lives at the edges.** Every model
25 tool we add is sent on every API call, so the bar for a new *core* tool is
26 high. Most new capability should arrive as a CLI command + skill, a
27 service-gated tool, or a plugin — not as core surface.
28 
29## Contribution Rubric — What We Want / What We Don't
30 
31This is the project's intent layer. Use it two ways:
32 
331. **For humans and for your own work** — what gets merged and what gets
34 rejected, so a contribution aims at the target.
352. **For automated review (the triage sweeper)** — guidance on when a PR is
36 safe to close on the three allowed reasons (`implemented_on_main`,
37 `cannot_reproduce`, `incoherent`) and, just as important, **when NOT to
38 close** one. Taste-based "we don't want this / out of scope" closes are NOT
39 an automated decision — those stay with a human maintainer. The sweeper's
40 job here is to recognize design intent and *avoid wrongly closing a
41 legitimate contribution*, not to make the won't-implement call itself.
42 
43Read the balance right: Hermes ships a **lot** — most merges are bug fixes to
44real reported behavior, and the product surface (platforms, channels,
45providers, models, desktop/TUI features) expands aggressively and on purpose.
46The restraint below is aimed squarely at the **core agent + the model tool
47schema**, the one place where every addition is paid for on every API call.
48"Smallest footprint" governs *how a capability is wired into the core*, NOT
49whether the product is allowed to grow. We are expansive at the edges and
50conservative at the waist.
51 
52### What we want
53 
54- **Fix real bugs, well.** The bulk of what lands is `fix(...)` against an
55 actual reported symptom. A good fix reproduces the symptom on current
56 `main`, points to the exact line where it manifests, and fixes the whole bug
57 class — sibling call paths included — not just the one site the reporter hit.
58- **Expand reach at the edges.** New platform adapters, channels, providers,
59 models, and desktop/TUI/dashboard features are welcome and land routinely,
60 including large ones (a new messaging channel, a session-cap feature, a
61 Windows PTY bridge). Breadth in the product is a goal, not a footprint
62 concern — as long as it integrates with the existing setup/config UX
63 (`hermes tools`, `hermes setup`, auto-install) rather than bolting on a raw
64 env var.
65- **Refactor god-files into clean modules.** Extracting a multi-thousand-line
66 cluster out of `cli.py` / `run_agent.py` / `gateway/run.py` into a focused
67 mixin or module is wanted work, even when the diff is huge and mechanical
68 (large `+N/-N` refactors merge regularly). The "every line traces to the
69 request" test applies to *feature* PRs; a declared refactor's request IS the
70 extraction.
71- **Keep the core narrow.** New *model tools* are the expensive exception —
72 every tool ships on every API call. Prefer, in order: extend existing code →
73 CLI command + skill → service-gated tool (`check_fn`) → plugin → MCP server
74 in the catalog → new core tool (last resort). See "The Footprint Ladder."
75- **Extend, don't duplicate.** Before adding a module/manager/hook, check
76 whether existing infrastructure already covers the use case. When several PRs
77 integrate the same *category*, design one shared interface instead of merging
78 them one at a time (see the ABC + orchestrator note under the Footprint
79 Ladder).
80- **Behavior contracts over snapshots.** Tests should assert how two pieces of
81 data must relate (invariants), not freeze a current value (model lists,
82 config version literals, enumeration counts). See "Don't write
83 change-detector tests."
84- **E2E validation, not just green unit mocks.** For anything touching
85 resolution chains, config propagation, security boundaries, remote
86 backends, or file/network I/O, exercise the real path with real imports
87 against a temp `HERMES_HOME`. Mocks hide integration bugs.
88- **Cache-, alternation-, and invariant-safe.** Preserve prompt caching, strict
89 message role alternation (never two same-role messages in a row; never a
90 synthetic user message injected mid-loop), and a system prompt that is
91 byte-stable for the life of a conversation.
92- **Contributor credit preserved.** Salvage external work by cherry-picking
93 (rebase-merge) so authorship survives in git history; don't reimplement from
94 scratch when you can build on top.
95 
96### What we don't want (rejected even when well-built)
97 
98- **Speculative infrastructure.** Hooks, callbacks, or extension points with no
99 concrete consumer. Adding a hook is easy; removing one after plugins depend
100 on it is hard. A hook is NOT speculative if a contributor has a real, stated
101 use case — even if the consumer ships separately.
102- **New `HERMES_*` env vars for non-secret config.** `.env` is for secrets
103 only (API keys, tokens, passwords). All behavioral settings — timeouts,
104 thresholds, feature flags, display prefs — go in `config.yaml`. Bridge to an
105 internal env var if the mechanism needs one, but user-facing docs point to
106 `config.yaml`. Reject PRs that tell users to "set X in your .env" unless X
107 is a credential.
108- **A new core tool when terminal + file already do the job, or when a skill
109 would.** If the only barrier is file visibility on a remote backend, fix the
110 mount, not the toolset.
111- **Lazy-reading escape hatches on instructional tools.** No `offset`/`limit`
112 pagination on tools that load content the agent must read fully (skills,
113 prompts, playbooks). Models will read page 1 and skip the rest.
114- **"Fixes" that destroy the feature they secure.** A mitigation that kills the
115 feature's purpose is the wrong mitigation. Read the original commit's intent
116 (`git log -p -S`) before restricting behavior; find a fix that preserves the
117 feature.
118- **Outbound telemetry / usage attribution without opt-in gating.** No new
119 analytics, third-party identifier tagging, or attribution tags until a
120 generic user-facing opt-in (config gate + setup prompt + `hermes tools`
121 toggle) exists. Park behind a label, do not merge.
122- **Change-detector tests, cache-breaking mid-conversation, dead code wired in
123 without E2E proof, and plugins that touch core files.** Plugins live in their
124 own directory and work within the ABCs/hooks we provide; if a plugin needs
125 more, widen the generic plugin surface, don't special-case it in core.
126- **Third-party products / other people's projects integrated into the core
127 tree.** Observability backends, vendor SaaS integrations, analytics dashboards,
128 and similar "someone else's product" plugins do NOT land under `plugins/` in
129 this repo. They place an ongoing maintenance burden on us to keep them working
130 against a fast-moving core, for a backend we don't own. Ship them as a
131 **standalone plugin repo** users install into `~/.hermes/plugins/` (or via a
132 pip entry point), and promote them in the Nous Research Discord
133 (`#plugins-skills-and-skins`). This is a coupling-and-maintenance decision, not
134 a quality bar — the plugin can be excellent and still be a close. PRs that add
135 such a directory to the tree are closed with a pointer to publish it as its own
136 repo.
137 
138### Before you call it a bug — verify the premise (and when NOT to close)
139 
140The most common reason a well-written PR gets closed is not code quality — it
141is that the change is built on a **wrong premise**, or it treats an
142**intentional design as a gap**. These patterns cut both ways: they tell a
143human reviewer what to scrutinize, and they tell the automated sweeper when a
144PR is NOT safe to close as `implemented_on_main` / `cannot_reproduce` (when in
145doubt, leave it open for a human). They are distilled from real closes.
146 
147- **"Intentional design, not a gap."** A limitation that looks like an
148 oversight is often deliberate. Before "fixing" a missing link or a
149 restriction, ask whether the isolation IS the design. Example: profiles are
150 independent islands on purpose — a PR adding live config inheritance from the
151 default profile was closed because coupling profiles together is exactly what
152 the design prevents (the copy-at-creation `--clone` path already covers the
153 legitimate "start from my default" case). Read the original commit's intent
154 (`git log -p -S "<symbol>"`) before assuming something is unfinished.
155- **"The premise doesn't hold against how X actually works."** A PR's
156 justification frequently rests on a wrong mental model of an existing
157 mechanism. Trace the real code/runtime before accepting the rationale. Two
158 real closes: a rate-limit "re-probe during cooldown" PR (the breaker only
159 trips on a *confirmed-empty* account bucket, so re-probing just hammers a
160 bucket we've already proven empty); a usage-accumulation fix whose new branch
161 **never executes at runtime** because an earlier guard already popped the
162 state it depended on. If you can't point to the exact line where the bug
163 manifests AND show the fix changes that line's behavior, you haven't verified
164 the premise.
165- **"This fix was wrong — the absence/omission was deliberate."** Adding the
166 obvious-looking missing piece can break things the omission was protecting.
167 Example: restoring "missing" `__init__.py` files made a test tree importable
168 as a dotted package that shadowed the real plugin, deleting its `register()`
169 at import time. The absence was load-bearing.
170- **"Overreached / resurrected an approach we'd moved past."** Scope creep that
171 supersedes an agreed-on base, or revives a direction the maintainers
172 deliberately closed, gets rejected even when the code works. Keep the change
173 to the narrow piece that was actually agreed; offer the rest as a focused
174 follow-up.
175 
176The throughline: **verify the claim AND the intent against the codebase before
177writing or merging a fix.** A confirmed reproduction on current `main` plus a
178line-level account of where the fix acts beats a plausible-sounding rationale
179every time. When in doubt about intent, it is cheaper to ask than to ship a
180fix that fights the design.
181 
182### The Footprint Ladder (new capability decision)
183 
184Each rung adds more permanent surface than the one above. Choose the highest
185(least-footprint) rung that correctly solves the problem:
186 
1871. **Extend existing code** — the capability is a variation of something that
188 already exists. Zero new surface.
1892. **CLI command + skill** — manages config/state/infra expressible as shell
190 commands. The agent runs `hermes <subcommand>` guided by a skill. Zero
191 model-tool footprint. Default choice for subscriptions, scheduled tasks,
192 service setup. Examples: `hermes webhook`, `hermes cron`, `hermes tools`.
1933. **Service-gated tool (`check_fn`)** — needs structured params/returns AND
194 only appears when a prerequisite is configured. Zero footprint otherwise.
195 Examples: Home Assistant tools (gated on token), memory-provider tools.
1964. **Plugin** — third-party/niche/user-specific capability that doesn't ship in
197 core. Lives in `~/.hermes/plugins/` or a pip package, discovered at runtime.
1985. **MCP server (in the catalog)** — if the capability genuinely needs to be a
199 tool (structured I/O the agent invokes) but isn't core-fundamental, prefer
200 building it as an MCP server and adding it to the MCP catalog over growing
201 the core toolset. The agent connects to it through the built-in MCP client;
202 zero permanent core-schema footprint, and it's reusable by any MCP host.
2036. **New core tool** — only when the capability is fundamental, broadly useful
204 to nearly every user, and unreachable via terminal + file (or an MCP server).
205 Examples of correct core tools: terminal, read_file, web_search,
206 browser_navigate.
207 
208When 3+ open PRs try to integrate the same *category* of thing (memory
209backends, providers, notifiers), don't merge them one at a time — design an
210ABC + orchestrator, wrap the existing built-in as the first provider, and turn
211the competing PRs into plugins against that interface.
212 
213## Development Environment
214 
215```bash
216# Prefer .venv; fall back to venv if that's what your checkout has.
217source .venv/bin/activate # or: source venv/bin/activate
218```
219 
220`scripts/run_tests.sh` probes `.venv` first, then `venv`, then
221`$HOME/.hermes/hermes-agent/venv` (for worktrees that share a venv with the
222main checkout).
223 
224## Project Structure
225 
226File counts shift constantly — don't treat the tree below as exhaustive.
227The canonical source is the filesystem. The notes call out the load-bearing
228entry points you'll actually edit.
229 
230```
231hermes-agent/
232├── run_agent.py # AIAgent class — core conversation loop (~12k LOC)
233├── model_tools.py # Tool orchestration, discover_builtin_tools(), handle_function_call()
234├── toolsets.py # Toolset definitions, _HERMES_CORE_TOOLS list
235├── cli.py # HermesCLI class — interactive CLI orchestrator (~11k LOC)
236├── hermes_state.py # SessionDB — SQLite session store (FTS5 search)
237├── hermes_constants.py # get_hermes_home(), display_hermes_home() — profile-aware paths
238├── hermes_logging.py # setup_logging() — agent.log / errors.log / gateway.log (profile-aware)
239├── batch_runner.py # Parallel batch processing
240├── agent/ # Agent internals (provider adapters, memory, caching, compression, etc.)
241├── hermes_cli/ # CLI subcommands, setup wizard, plugins loader, skin engine
242├── tools/ # Tool implementations — auto-discovered via tools/registry.py
243│ └── environments/ # Terminal backends (local, docker, ssh, modal, daytona, singularity)
244├── gateway/ # Messaging gateway — run.py + session.py + platforms/
245│ ├── platforms/ # Adapter per platform (telegram, discord, slack, whatsapp,
246│ │ # homeassistant, signal, matrix, mattermost, email, sms,
247│ │ # dingtalk, wecom, weixin, feishu, qqbot, bluebubbles,
248│ │ # yuanbao, webhook, api_server, ...). See ADDING_A_PLATFORM.md.
249│ └── builtin_hooks/ # Extension point for always-registered gateway hooks (none shipped)
250├── plugins/ # Plugin system (see "Plugins" section below)
251│ ├── memory/ # Memory-provider plugins (honcho, mem0, supermemory, ...)
252│ ├── context_engine/ # Context-engine plugins
253│ ├── model-providers/ # Inference backend plugins (openrouter, anthropic, gmi, ...)
254│ ├── kanban/ # Multi-agent board dispatcher + worker plugin
255│ ├── hermes-achievements/ # Gamified achievement tracking
256│ ├── observability/ # Metrics / traces / logs plugin
257│ ├── image_gen/ # Image-generation providers
258│ └── <others>/ # disk-cleanup, google_meet, platforms, spotify,
259│ # strike-freedom-cockpit, ...
260├── optional-skills/ # Heavier/niche skills shipped but NOT active by default
261├── skills/ # Built-in skills bundled with the repo
262├── ui-tui/ # Ink (React) terminal UI — `hermes --tui`
263│ └── src/ # entry.tsx, app.tsx, gatewayClient.ts + app/components/hooks/lib
264├── tui_gateway/ # Python JSON-RPC backend for the TUI
265├── acp_adapter/ # ACP server (VS Code / Zed / JetBrains integration)
266├── cron/ # Scheduler — jobs.py, scheduler.py
267├── scripts/ # run_tests.sh, release.py, auxiliary scripts
268├── website/ # Docusaurus docs site
269└── tests/ # Pytest suite (~17k tests across ~900 files as of May 2026)
270```
271 
272**User config:** `~/.hermes/config.yaml` (settings), `~/.hermes/.env` (API keys only).
273**Logs:** `~/.hermes/logs/` — `agent.log` (INFO+), `errors.log` (WARNING+),
274`gateway.log` when running the gateway. Profile-aware via `get_hermes_home()`.
275Browse with `hermes logs [--follow] [--level ...] [--session ...]`.
276 
277## TypeScript Style
278 
279Applies to TypeScript across Hermes: desktop, TUI, website, and future TS packages.
280 
281- Prefer small nanostores over component state when state is shared, reused, or read by distant UI.
282- Let each feature own its atoms. Chat state belongs near chat, shell state near shell, shared state in `src/store`.
283- Components that render from an atom should use `useStore`. Non-rendering actions should read with `$atom.get()`.
284- Do not pass state through three components when the leaf can subscribe to the atom.
285- Keep persistence beside the atom that owns it.
286- Keep route roots thin. They compose routes and shell; they should not become controllers.
287- No monolithic hooks. A hook should own one narrow job.
288- Prefer colocated action modules over hidden god hooks.
289- If a callback is pure side effect, use the terse void form:
290 `onState={st => void setGatewayState(st)}`.
291- Async UI handlers should make intent explicit:
292 `onClick={() => void save()}`.
293- Prefer interfaces for public props and shared object shapes. Avoid `type X = { ... }` for object props.
294- Extend React primitives for props: `React.ComponentProps<'button'>`, `React.ComponentProps<typeof Dialog>`, `Omit<...>`, `Pick<...>`.
295- Table-driven beats condition ladders when mapping ids, routes, or views.
296- `src/app` owns routes, pages, and page-specific components.
297- `src/store` owns shared atoms.
298- `src/lib` owns shared pure helpers.
299 
300## File Dependency Chain
301 
302```
303tools/registry.py (no deps — imported by all tool files)
304 ↑
305tools/*.py (each calls registry.register() at import time)
306 ↑
307model_tools.py (imports tools/registry + triggers tool discovery)
308 ↑
309run_agent.py, cli.py, batch_runner.py, environments/
310```
311 
312---
313 
314## AIAgent Class (run_agent.py)
315 
316The real `AIAgent.__init__` takes ~60 parameters (credentials, routing, callbacks,
317session context, budget, credential pool, etc.). The signature below is the
318minimum subset you'll usually touch — read `run_agent.py` for the full list.
319 
320```python
321class AIAgent:
322 def __init__(self,
323 base_url: str = None,
324 api_key: str = None,
325 provider: str = None,
326 api_mode: str = None, # "chat_completions" | "codex_responses" | ...
327 model: str = "", # empty → resolved from config/provider later
328 max_iterations: int = 500, # tool-calling iterations (shared with subagents)
329 enabled_toolsets: list = None,
330 disabled_toolsets: list = None,
331 quiet_mode: bool = False,
332 save_trajectories: bool = False,
333 platform: str = None, # "cli", "telegram", etc.
334 session_id: str = None,
335 skip_context_files: bool = False,
336 skip_memory: bool = False,
337 credential_pool=None,
338 # ... plus callbacks, thread/user/chat IDs, iteration_budget, fallback_model,
339 # checkpoints config, prefill_messages, service_tier, reasoning_config, etc.
340 ): ...
341 
342 def chat(self, message: str) -> str:
343 """Simple interface — returns final response string."""
344 
345 def run_conversation(self, user_message: str, system_message: str = None,
346 conversation_history: list = None, task_id: str = None) -> dict:
347 """Full interface — returns dict with final_response + messages."""
348```
349 
350### Agent Loop
351 
352The core loop is inside `run_conversation()` — entirely synchronous, with
353interrupt checks, budget tracking, and a one-turn grace call:
354 
355```python
356while (api_call_count < self.max_iterations and self.iteration_budget.remaining > 0) \
357 or self._budget_grace_call:
358 if self._interrupt_requested: break
359 response = client.chat.completions.create(model=model, messages=messages, tools=tool_schemas)
360 if response.tool_calls:
361 for tool_call in response.tool_calls:
362 result = handle_function_call(tool_call.name, tool_call.args, task_id)
363 messages.append(tool_result_message(result))
364 api_call_count += 1
365 else:
366 return response.content
367```
368 
369Messages follow OpenAI format: `{"role": "system/user/assistant/tool", ...}`.
370Reasoning content is stored in `assistant_msg["reasoning"]`.
371 
372---
373 
374## CLI Architecture (cli.py)
375 
376- **Rich** for banner/panels, **prompt_toolkit** for input with autocomplete
377- **KawaiiSpinner** (`agent/display.py`) — animated faces during API calls, `┊` activity feed for tool results
378- `load_cli_config()` in cli.py merges hardcoded defaults + user config YAML
379- **Skin engine** (`hermes_cli/skin_engine.py`) — data-driven CLI theming; initialized from `display.skin` config key at startup; skins customize banner colors, spinner faces/verbs/wings, tool prefix, response box, branding text
380- `process_command()` is a method on `HermesCLI` — dispatches on canonical command name resolved via `resolve_command()` from the central registry
381- Skill slash commands: `agent/skill_commands.py` scans `~/.hermes/skills/`, injects as **user message** (not system prompt) to preserve prompt caching
382 
383### Slash Command Registry (`hermes_cli/commands.py`)
384 
385All slash commands are defined in a central `COMMAND_REGISTRY` list of `CommandDef` objects. Every downstream consumer derives from this registry automatically:
386 
387- **CLI** — `process_command()` resolves aliases via `resolve_command()`, dispatches on canonical name
388- **Gateway** — `GATEWAY_KNOWN_COMMANDS` frozenset for hook emission, `resolve_command()` for dispatch
389- **Gateway help** — `gateway_help_lines()` generates `/help` output
390- **Telegram** — `telegram_bot_commands()` generates the BotCommand menu
391- **Slack** — `slack_subcommand_map()` generates `/hermes` subcommand routing
392- **Autocomplete** — `COMMANDS` flat dict feeds `SlashCommandCompleter`
393- **CLI help** — `COMMANDS_BY_CATEGORY` dict feeds `show_help()`
394 
395### Adding a Slash Command
396 
3971. Add a `CommandDef` entry to `COMMAND_REGISTRY` in `hermes_cli/commands.py`:
398```python
399CommandDef("mycommand", "Description of what it does", "Session",
400 aliases=("mc",), args_hint="[arg]"),
401```
4022. Add handler in `HermesCLI.process_command()` in `cli.py`:
403```python
404elif canonical == "mycommand":
405 self._handle_mycommand(cmd_original)
406```
4073. If the command is available in the gateway, add a handler in `gateway/run.py`:
408```python
409if canonical == "mycommand":
410 return await self._handle_mycommand(event)
411```
4124. For persistent settings, use `save_config_value()` in `cli.py`
413 
414**CommandDef fields:**
415- `name` — canonical name without slash (e.g. `"background"`)
416- `description` — human-readable description
417- `category` — one of `"Session"`, `"Configuration"`, `"Tools & Skills"`, `"Info"`, `"Exit"`
418- `aliases` — tuple of alternative names (e.g. `("bg",)`)
419- `args_hint` — argument placeholder shown in help (e.g. `"<prompt>"`, `"[name]"`)
420- `cli_only` — only available in the interactive CLI
421- `gateway_only` — only available in messaging platforms
422- `gateway_config_gate` — config dotpath (e.g. `"display.tool_progress_command"`); when set on a `cli_only` command, the command becomes available in the gateway if the config value is truthy. `GATEWAY_KNOWN_COMMANDS` always includes config-gated commands so the gateway can dispatch them; help/menus only show them when the gate is open.
423 
424**Adding an alias** requires only adding it to the `aliases` tuple on the existing `CommandDef`. No other file changes needed — dispatch, help text, Telegram menu, Slack mapping, and autocomplete all update automatically.
425 
426---
427 
428## TUI Architecture (ui-tui + tui_gateway)
429 
430The TUI is a full replacement for the classic (prompt_toolkit) CLI, activated via `hermes --tui` or `HERMES_TUI=1`.
431 
432### Process Model
433 
434```
435hermes --tui
436 └─ Node (Ink) ──stdio JSON-RPC── Python (tui_gateway)
437 │ └─ AIAgent + tools + sessions
438 └─ renders transcript, composer, prompts, activity
439```
440 
441TypeScript owns the screen. Python owns sessions, tools, model calls, and slash command logic.
442 
443### Transport
444 
445Newline-delimited JSON-RPC over stdio. Requests from Ink, events from Python. See `tui_gateway/server.py` for the full method/event catalog.
446 
447### Key Surfaces
448 
449| Surface | Ink component | Gateway method |
450|---------|---------------|----------------|
451| Chat streaming | `app.tsx` + `messageLine.tsx` | `prompt.submit` → `message.delta/complete` |
452| Tool activity | `thinking.tsx` | `tool.start/progress/complete` |
453| Approvals | `prompts.tsx` | `approval.respond` ← `approval.request` |
454| Clarify/sudo/secret | `prompts.tsx`, `maskedPrompt.tsx` | `clarify/sudo/secret.respond` |
455| Session picker | `sessionPicker.tsx` | `session.list/resume` |
456| Slash commands | Local handler + fallthrough | `slash.exec` → `_SlashWorker`, `command.dispatch` |
457| Completions | `useCompletion` hook | `complete.slash`, `complete.path` |
458| Theming | `theme.ts` + `branding.tsx` | `gateway.ready` with skin data |
459 
460### Slash Command Flow
461 
4621. Built-in client commands (`/help`, `/quit`, `/clear`, `/resume`, `/copy`, `/paste`, etc.) handled locally in `app.tsx`
4632. Everything else → `slash.exec` (runs in persistent `_SlashWorker` subprocess) → `command.dispatch` fallback
464 
465### Dev Commands
466 
467```bash
468cd ui-tui
469npm install # first time
470npm run dev # watch mode (rebuilds hermes-ink + tsx --watch)
471npm start # production
472npm run build # full build (hermes-ink + tsc)
473npm run typecheck # typecheck only (tsc --noEmit)
474npm run lint # eslint
475npm run fmt # prettier
476npm test # vitest
477```
478 
479### TUI in the Dashboard (`hermes dashboard` → `/chat`)
480 
481The dashboard embeds the real `hermes --tui` — **not** a rewrite. See `hermes_cli/pty_bridge.py` + the `@app.websocket("/api/pty")` endpoint in `hermes_cli/web_server.py`.
482 
483- Browser loads `web/src/pages/ChatPage.tsx`, which mounts xterm.js's `Terminal` with the WebGL renderer, `@xterm/addon-fit` for container-driven resize, and `@xterm/addon-unicode11` for modern wide-character widths.
484- `/api/pty?token=…` upgrades to a WebSocket; auth uses the same ephemeral `_SESSION_TOKEN` as REST, via query param (browsers can't set `Authorization` on WS upgrade).
485- The server spawns whatever `hermes --tui` would spawn, through `ptyprocess` (POSIX PTY — WSL works, native Windows does not).
486- Frames: raw PTY bytes each direction; resize via `\x1b[RESIZE:<cols>;<rows>]` intercepted on the server and applied with `TIOCSWINSZ`.
487 
488**Do not re-implement the primary chat experience in React.** The main transcript, composer/input flow (including slash-command behavior), and PTY-backed terminal belong to the embedded `hermes --tui` — anything new you add to Ink shows up in the dashboard automatically. If you find yourself rebuilding the transcript or composer for the dashboard, stop and extend Ink instead.
489 
490**Structured React UI around the TUI is allowed when it is not a second chat surface.** Sidebar widgets, inspectors, summaries, status panels, and similar supporting views (e.g. `ChatSidebar`, `ModelPickerDialog`, `ToolCall`) are fine when they complement the embedded TUI rather than replacing the transcript / composer / terminal. Keep their state independent of the PTY child's session and surface their failures non-destructively so the terminal pane keeps working unimpaired.
491 
492### Electron Desktop Chat App (`apps/desktop/`)
493 
494A **separate** chat surface from both the classic CLI and the dashboard's embedded TUI. It is an Electron + React + nanostore renderer (`@assistant-ui/react`) that talks to a `tui_gateway` backend over JSON-RPC (`requestGateway(method, params)`). The WebSocket/JSON-RPC transport lives in the framework-agnostic `apps/shared` package (`@hermes/shared` — `JsonRpcGatewayClient` + WS URL helpers), which the web dashboard (`web/`) also consumes; **desktop has no build/runtime dependency on the dashboard frontend** — it spawns a headless `hermes serve` backend server (the same gateway `dashboard` serves, minus the browser UI entirely: `serve` sets `headless_backend=True`, so `cmd_dashboard` skips `_build_web_ui` AND exports `HERMES_SERVE_HEADLESS=1` so `mount_spa()` disables the SPA even if a stray `web_dist/` exists — only the JSON-RPC/WS/API surface is reachable). `dashboard` and `serve` share `cmd_dashboard`/`start_server` but are independent surfaces — neither launches the other. The one exception is a backward-compat *fallback*: `serve` is newer, so the desktop spawn (`electron/backend-command.ts` + `backendSupportsServe()` in `electron/main.ts`) detects whether the resolved runtime registers `serve` and, only when it does not (an older managed install / PATH `hermes` the app hasn't updated yet), rewrites the argv to the legacy `dashboard --no-open`. Without that, a new app against an un-upgraded runtime would crash on an unknown subcommand and brick every mid-upgrade user. It does NOT embed `hermes --tui` — it has its own composer, transcript, and slash-command pipeline. For scoped Desktop architecture, state, resolver, transport, and testing rules, read `apps/desktop/AGENTS.md`.
495 
496**Slash commands in the desktop app are curated client-side, then dispatched to the backend.** The pipeline:
497 
498- **Backend already provides everything.** `tui_gateway/server.py` `commands.catalog` (empty-query list) and `complete.slash` (typed-query completions) both include built-in commands, user `quick_commands`, AND skill-derived commands (`scan_skill_commands()` / `get_skill_commands()`). The desktop app does not need a new RPC to see skills.
499- **The renderer curates via `apps/desktop/src/lib/desktop-slash-commands.ts`.** This is the load-bearing file. It holds `DESKTOP_COMMAND_SPECS` (the built-ins and their Desktop surfaces) plus `NO_DESKTOP_SURFACE` block-lists for terminal-only / messaging-only / picker-owned / settings-owned / advanced commands that should NOT clutter the desktop popover.
500 - `isDesktopSlashCommand(name)` — gates **execution**. Returns true for built-ins AND for any non-built-in (skill / quick command), so typed extension commands run.
501 - `isDesktopSlashSuggestion(name)` — gates **discovery/completion**. Used by BOTH completion paths in `app/chat/composer/hooks/use-slash-completions.ts` (empty-query catalog filter + typed-query `complete.slash` filter) and by `filterDesktopCommandsCatalog`.
502 - `isDesktopSlashExtensionCommand(name)` — true when the command is NOT a known Hermes built-in (i.e. a skill or user quick command). Both suggestion and catalog-filter paths allow extensions through so skill commands surface in the palette. (Added when fixing "skill commands missing from the desktop slash palette" — the curated allow-list was silently dropping every skill/quick command from completions even though they executed fine when typed.)
503- **Dispatch** lives in `app/session/hooks/use-prompt-actions/slash.ts` (`runSlash`): built-ins that the desktop owns (`/skin`, `/help`, `/new`, …) are handled locally or via `commands.catalog`; everything else goes to `slash.exec`, falling back to `command.dispatch` (which the gateway resolves into skill / alias / exec directives). A skill command resolves to `{type: "skill", message}` and is submitted as a normal prompt.
504 
505**Rule:** the desktop slash palette's curation is about hiding noise (terminal-only / messaging-only built-ins), NOT about hiding user-activated extensions. Skill commands and `quick_commands` are extensions the backend surfaces — they belong in completions. If you tighten `desktop-slash-commands.ts`, keep `isDesktopSlashExtensionCommand` flowing into both the suggestion and catalog-filter paths. Tests: from `apps/desktop`, run `npx vitest run src/lib/desktop-slash-commands.test.ts` (workspace dependencies are installed at the repo root).
506 
507---
508 
509## Adding New Tools
510 
511Before adding any tool, settle the footprint question first (see "The
512Footprint Ladder" in the Contribution Rubric): most capabilities should NOT
513be core tools. For custom or local-only tools, do **not** edit Hermes core.
514Use the plugin route instead: create `~/.hermes/plugins/<name>/plugin.yaml`
515and `~/.hermes/plugins/<name>/__init__.py`, then register tools with
516`ctx.register_tool(...)`. Plugin toolsets are discovered automatically and can be
517enabled or disabled without touching `tools/` or `toolsets.py`.
518 
519Use the built-in route below only when the user is explicitly contributing a new
520core Hermes tool that should ship in the base system.
521 
522Built-in/core tools require changes in **2 files**:
523 
524**1. Create `tools/your_tool.py`:**
525```python
526import json, os
527from tools.registry import registry
528 
529def check_requirements() -> bool:
530 return bool(os.getenv("EXAMPLE_API_KEY"))
531 
532def example_tool(param: str, task_id: str = None) -> str:
533 return json.dumps({"success": True, "data": "..."})
534 
535registry.register(
536 name="example_tool",
537 toolset="example",
538 schema={"name": "example_tool", "description": "...", "parameters": {...}},
539 handler=lambda args, **kw: example_tool(param=args.get("param", ""), task_id=kw.get("task_id")),
540 check_fn=check_requirements,
541 requires_env=["EXAMPLE_API_KEY"],
542)
543```
544 
545**2. Add to `toolsets.py`** — either `_HERMES_CORE_TOOLS` (all platforms) or a new toolset. **This step is required:** auto-discovery imports the tool and registers its schema, but the tool is only *exposed to an agent* if its name appears in a toolset. `_HERMES_CORE_TOOLS` is not dead code — it's the default bundle every platform's base toolset inherits from.
546 
547Auto-discovery: any `tools/*.py` file with a top-level `registry.register()` call is imported automatically — no manual import list to maintain. Wiring into a toolset is still a deliberate, manual step.
548 
549The registry handles schema collection, dispatch, availability checking, and error wrapping. All handlers MUST return a JSON string.
550 
551**Path references in tool schemas**: If the schema description mentions file paths (e.g. default output directories), use `display_hermes_home()` to make them profile-aware. The schema is generated at import time, which is after `_apply_profile_override()` sets `HERMES_HOME`.
552 
553**State files**: If a tool stores persistent state (caches, logs, checkpoints), use `get_hermes_home()` for the base directory — never `Path.home() / ".hermes"`. This ensures each profile gets its own state.
554 
555**Agent-level tools** (todo, memory): intercepted by `run_agent.py` before `handle_function_call()`. See `tools/todo_tool.py` for the pattern.
556 
557---
558 
559## Dependency Pinning Policy
560 
561All dependencies must have upper bounds to limit supply-chain attack surface.
562This policy was established after the litellm compromise (PR #2796, #2810) and
563reinforced after the Mini Shai-Hulud worm campaign (May 2026).
564 
565| Source type | Treatment | Example |
566|---|---|---|
567| PyPI package | `>=floor,<next_major` | `"httpx>=0.28.1,<1"` |
568| Git URL | Commit SHA | `git+https://...@<40-char-sha>` |
569| GitHub Actions | Commit SHA + comment | `uses: actions/checkout@<sha> # v4` |
570| CI-only pip | `==exact` | `pyyaml==6.0.2` |
571 
572**When adding a new dependency to `pyproject.toml`:**
5731. Pin to `>=current_version,<next_major` for post-1.0 (e.g. `>=1.5.0,<2`).
5742. For pre-1.0 packages, use `<0.(current_minor + 2)` (e.g. `>=0.29,<0.32`).
5753. Never commit a bare `>=X.Y.Z` without a ceiling — CI and reviewers will reject it.
5764. Run `uv lock` to regenerate `uv.lock` with hashes.
577 
578Reference: #2810 (bounds pass), #9801 (SHA pinning + audit CI).
579 
580---
581 
582## Adding Configuration
583 
584### config.yaml options:
5851. Add to `DEFAULT_CONFIG` in `hermes_cli/config.py`
5862. Bump `_config_version` (check the current value at the top of `DEFAULT_CONFIG`)
587 ONLY if you need to actively migrate/transform existing user config
588 (renaming keys, changing structure). Adding a new key to an existing
589 section is handled automatically by the deep-merge and does NOT require
590 a version bump.
591 
592### Top-level `config.yaml` sections (non-exhaustive):
593 
594`model`, `agent`, `terminal`, `compression`, `display`, `stt`, `tts`,
595`memory`, `security`, `delegation`, `smart_model_routing`, `checkpoints`,
596`auxiliary`, `curator`, `skills`, `gateway`, `logging`, `cron`, `profiles`,
597`plugins`, `honcho`.
598 
599`auxiliary` holds per-task overrides for side-LLM work (curator, vision,
600embedding, title generation, session_search, etc.) — each task can pin
601its own provider/model/base_url/max_tokens/reasoning_effort. See
602`agent/auxiliary_client.py::_resolve_auto` for resolution order.
603 
604`curator` holds the background skill-maintenance config —
605`enabled`, `interval_hours`, `min_idle_hours`, `stale_after_days`,
606`archive_after_days`, `backup` (nested).
607 
608### .env variables (SECRETS ONLY — API keys, tokens, passwords):
6091. Add to `OPTIONAL_ENV_VARS` in `hermes_cli/config.py` with metadata:
610```python
611"NEW_API_KEY": {
612 "description": "What it's for",
613 "prompt": "Display name",
614 "url": "https://...",
615 "password": True,
616 "category": "tool", # provider, tool, messaging, setting
617},
618```
619 
620Non-secret settings (timeouts, thresholds, feature flags, paths, display
621preferences) belong in `config.yaml`, not `.env`. If internal code needs an
622env var mirror for backward compatibility, bridge it from `config.yaml` to
623the env var in code (see `gateway_timeout`, `terminal.cwd` → `TERMINAL_CWD`).
624 
625### Config loaders (three paths — know which one you're in):
626 
627| Loader | Used by | Location |
628|--------|---------|----------|
629| `load_cli_config()` | CLI mode | `cli.py` — merges CLI-specific defaults + user YAML |
630| `load_config()` | `hermes tools`, `hermes setup`, most CLI subcommands | `hermes_cli/config.py` — merges `DEFAULT_CONFIG` + user YAML |
631| Direct YAML load | Gateway runtime | `gateway/run.py` + `gateway/config.py` — reads user YAML raw |
632 
633If you add a new key and the CLI sees it but the gateway doesn't (or vice
634versa), you're on the wrong loader. Check `DEFAULT_CONFIG` coverage.
635 
636### Working directory:
637- **CLI** — uses the process's current directory (`os.getcwd()`).
638- **Messaging** — uses `terminal.cwd` from `config.yaml`. The gateway bridges this
639 to the `TERMINAL_CWD` env var for child tools. **`MESSAGING_CWD` has been
640 removed** — the config loader prints a deprecation warning if it's set in
641 `.env`. Same for `TERMINAL_CWD` in `.env`; the canonical setting is
642 `terminal.cwd` in `config.yaml`.
643 
644---
645 
646## Skin/Theme System
647 
648The skin engine (`hermes_cli/skin_engine.py`) provides data-driven CLI visual customization. Skins are **pure data** — no code changes needed to add a new skin.
649 
650### Architecture
651 
652```
653hermes_cli/skin_engine.py # SkinConfig dataclass, built-in skins, YAML loader
654~/.hermes/skins/*.yaml # User-installed custom skins (drop-in)
655```
656 
657- `init_skin_from_config()` — called at CLI startup, reads `display.skin` from config
658- `get_active_skin()` — returns cached `SkinConfig` for the current skin
659- `set_active_skin(name)` — switches skin at runtime (used by `/skin` command)
660- `load_skin(name)` — loads from user skins first, then built-ins, then falls back to default
661- Missing skin values inherit from the `default` skin automatically
662 
663### What skins customize
664 
665| Element | Skin Key | Used By |
666|---------|----------|---------|
667| Banner panel border | `colors.banner_border` | `banner.py` |
668| Banner panel title | `colors.banner_title` | `banner.py` |
669| Banner section headers | `colors.banner_accent` | `banner.py` |
670| Banner dim text | `colors.banner_dim` | `banner.py` |
671| Banner body text | `colors.banner_text` | `banner.py` |
672| Response box border | `colors.response_border` | `cli.py` |
673| Spinner faces (waiting) | `spinner.waiting_faces` | `display.py` |
674| Spinner faces (thinking) | `spinner.thinking_faces` | `display.py` |
675| Spinner verbs | `spinner.thinking_verbs` | `display.py` |
676| Spinner wings (optional) | `spinner.wings` | `display.py` |
677| Tool output prefix | `tool_prefix` | `display.py` |
678| Per-tool emojis | `tool_emojis` | `display.py` → `get_tool_emoji()` |
679| Agent name | `branding.agent_name` | `banner.py`, `cli.py` |
680| Welcome message | `branding.welcome` | `cli.py` |
681| Response box label | `branding.response_label` | `cli.py` |
682| Prompt symbol | `branding.prompt_symbol` | `cli.py` |
683 
684### Built-in skins
685 
686- `default` — Classic Hermes gold/kawaii (the current look)
687- `ares` — Crimson/bronze war-god theme with custom spinner wings
688- `mono` — Clean grayscale monochrome
689- `slate` — Cool blue developer-focused theme
690 
691### Adding a built-in skin
692 
693Add to `_BUILTIN_SKINS` dict in `hermes_cli/skin_engine.py`:
694 
695```python
696"mytheme": {
697 "name": "mytheme",
698 "description": "Short description",
699 "colors": { ... },
700 "spinner": { ... },
701 "branding": { ... },
702 "tool_prefix": "┊",
703},
704```
705 
706### User skins (YAML)
707 
708Users create `~/.hermes/skins/<name>.yaml`:
709 
710```yaml
711name: cyberpunk
712description: Neon-soaked terminal theme
713
714colors:
715 banner_border: "#FF00FF"
716 banner_title: "#00FFFF"
717 banner_accent: "#FF1493"
718
719spinner:
720 thinking_verbs: ["jacking in", "decrypting", "uploading"]
721 wings:
722 - ["⟨⚡", "⚡⟩"]
723
724branding:
725 agent_name: "Cyber Agent"
726 response_label: " ⚡ Cyber "
727
728tool_prefix: "▏"
729```
730 
731Activate with `/skin cyberpunk` or `display.skin: cyberpunk` in config.yaml.
732 
733---
734 
735## Plugins
736 
737Hermes has two plugin surfaces. Both live under `plugins/` in the repo so
738repo-shipped plugins can be discovered alongside user-installed ones in
739`~/.hermes/plugins/` and pip-installed entry points.
740 
741### General plugins (`hermes_cli/plugins.py` + `plugins/<name>/`)
742 
743`PluginManager` discovers plugins from `~/.hermes/plugins/`, `./.hermes/plugins/`,
744and pip entry points. Each plugin exposes a `register(ctx)` function that
745can:
746 
747- Register Python-callback lifecycle hooks:
748 `pre_tool_call`, `post_tool_call`, `pre_llm_call`, `post_llm_call`,
749 `on_session_start`, `on_session_end`
750- Register new tools via `ctx.register_tool(...)`
751- Register CLI subcommands via `ctx.register_cli_command(...)` — the
752 plugin's argparse tree is wired into `hermes` at startup so
753 `hermes <pluginname> <subcmd>` works with no change to `main.py`
754 
755Hooks are invoked from `model_tools.py` (pre/post tool) and `run_agent.py`
756(lifecycle). **Discovery timing pitfall:** `discover_plugins()` only runs
757as a side effect of importing `model_tools.py`. Code paths that read plugin
758state without importing `model_tools.py` first must call `discover_plugins()`
759explicitly (it's idempotent).
760 
761### Memory-provider plugins (`plugins/memory/<name>/`)
762 
763Separate discovery system for pluggable memory backends. Current built-in
764providers include **honcho, mem0, supermemory, byterover, hindsight,
765holographic, openviking, retaindb**.
766 
767Each provider implements the `MemoryProvider` ABC (see `agent/memory_provider.py`)
768and is orchestrated by `agent/memory_manager.py`. Lifecycle hooks include
769`sync_turn(turn_messages)`, `prefetch(query)`, `shutdown()`, and optional
770`post_setup(hermes_home, config)` for setup-wizard integration.
771 
772**CLI commands via `plugins/memory/<name>/cli.py`:** if a memory plugin
773defines `register_cli(subparser)`, `discover_plugin_cli_commands()` finds
774it at argparse setup time and wires it into `hermes <plugin>`. The
775framework only exposes CLI commands for the **currently active** memory
776provider (read from `memory.provider` in config.yaml), so disabled
777providers don't clutter `hermes --help`.
778 
779**Rule (Teknium, May 2026):** plugins MUST NOT modify core files
780(`run_agent.py`, `cli.py`, `gateway/run.py`, `hermes_cli/main.py`, etc.).
781If a plugin needs a capability the framework doesn't expose, expand the
782generic plugin surface (new hook, new ctx method) — never hardcode
783plugin-specific logic into core. PR #5295 removed 95 lines of hardcoded
784honcho argparse from `main.py` for exactly this reason.
785 
786**No new in-tree memory providers (policy, May 2026):** the set of
787built-in memory providers under `plugins/memory/` is closed. New memory
788backends must ship as **standalone plugin repos** that users install
789into `~/.hermes/plugins/` (or via pip entry points) — they implement
790the same `MemoryProvider` ABC, register through the same discovery
791path, and integrate via `hermes memory setup` / `post_setup()` without
792landing in this tree. PRs that add a new directory under
793`plugins/memory/` will be closed with a pointer to publish the
794provider as its own repo. Existing in-tree providers stay; bug fixes
795to them are welcome.
796 
797**No new third-party-product plugins in-tree (policy, June 2026):** the
798same rule applies beyond memory providers. Plugins that integrate
799someone else's product or project — observability/metrics backends,
800vendor SaaS connectors, analytics dashboards, paid-service tie-ins —
801must ship as **standalone plugin repos** that users install into
802`~/.hermes/plugins/` (or via pip entry points). They register through
803the existing plugin discovery path and use the ABCs/hooks/ctx surface
804we expose; nothing special is needed in core. The reason is
805maintenance load: every product we absorb into the tree becomes our
806burden to keep working against a fast-moving core, for a backend we
807don't own. Promote standalone plugins in the Nous Research Discord
808(`#plugins-skills-and-skins`). PRs that add such a directory under
809`plugins/` are closed with a pointer to publish it as its own repo —
810this is a coupling decision, not a quality judgment. (The
811`observability/`, `kanban/`, `disk-cleanup/`, etc. directories already
812in the tree are existing precedent, not an invitation to add more
813third-party-product plugins alongside them.)
814 
815### Model-provider plugins (`plugins/model-providers/<name>/`)
816 
817Every inference backend (openrouter, anthropic, gmi, deepseek, nvidia, …)
818ships as a plugin here. Each plugin's `__init__.py` calls
819`providers.register_provider(ProviderProfile(...))` at module load.
820`providers/__init__.py._discover_providers()` is a **lazy, separate
821discovery system** — scanned on first `get_provider_profile()` or
822`list_providers()` call, NOT by the general PluginManager.
823 
824Scan order:
8251. Bundled: `<repo>/plugins/model-providers/<name>/`
8262. User: `$HERMES_HOME/plugins/model-providers/<name>/`
8273. Legacy: `<repo>/providers/<name>.py` (back-compat)
828 
829User plugins of the same name override bundled ones — `register_provider()`
830is last-writer-wins. This lets third parties swap out any built-in
831profile without a repo patch.
832 
833The general PluginManager records `kind: model-provider` manifests but does
834NOT import them (would double-instantiate `ProviderProfile`). Plugins
835without an explicit `kind:` get auto-coerced via a source-text heuristic
836(`register_provider` + `ProviderProfile` in `__init__.py`).
837 
838Full authoring guide: `website/docs/developer-guide/model-provider-plugin.md`.
839 
840### Dashboard / context-engine / image-gen plugin directories
841 
842`plugins/context_engine/`, `plugins/image_gen/`, etc. follow the same
843pattern (ABC + orchestrator + per-plugin directory). Context engines
844plug into `agent/context_engine.py`; image-gen providers into
845`agent/image_gen_provider.py`. Reference / docs-companion plugins
846(`example-dashboard`, `strike-freedom-cockpit`, `plugin-llm-example`,
847`plugin-llm-async-example`) live in the
848[`hermes-example-plugins`](https://github.com/NousResearch/hermes-example-plugins)
849companion repo, not in this tree.
850 
851---
852 
853## Skills
854 
855Two parallel surfaces:
856 
857- **`skills/`** — built-in skills shipped and loadable by default.
858 Organized by category directories (e.g. `skills/github/`, `skills/mlops/`).
859- **`optional-skills/`** — heavier or niche skills shipped with the repo but
860 NOT active by default. Installed explicitly via
861 `hermes skills install official/<category>/<skill>`. Adapter lives in
862 `tools/skills_hub.py` (`OptionalSkillSource`). Categories include
863 `autonomous-ai-agents`, `blockchain`, `communication`, `creative`,
864 `devops`, `email`, `health`, `mcp`, `migration`, `mlops`, `productivity`,
865 `research`, `security`, `web-development`.
866 
867When reviewing skill PRs, check which directory they target — heavy-dep or
868niche skills belong in `optional-skills/`.
869 
870### SKILL.md frontmatter
871 
872Standard fields: `name`, `description`, `version`, `author`, `license`,
873`platforms` (OS-gating list: `[macos]`, `[linux, macos]`, ...),
874`metadata.hermes.tags`, `metadata.hermes.category`,
875`metadata.hermes.related_skills`, `metadata.hermes.config` (config.yaml
876settings the skill needs — stored under `skills.config.<key>`, prompted
877during setup, injected at load time).
878 
879Top-level `tags:` and `category:` are also accepted and mirrored from
880`metadata.hermes.*` by the loader.
881 
882### Skill authoring standards (HARDLINE)
883 
884Every new or modernized skill — bundled, optional, or contributed —
885must meet these standards before merge. Reviewers reject PRs that
886violate them.
887 
8881. **`description` ≤ 60 characters, one sentence, ends with a period.**
889 Long descriptions bloat skill listings and dilute the model's
890 attention when many skills are loaded. State the capability, not
891 the implementation. No marketing words ("powerful",
892 "comprehensive", "seamless", "advanced"). Don't repeat the skill
893 name. Verify with:
894```python
895 import re, pathlib
896 m = re.search(r'^description: (.*)$',
897 pathlib.Path('skills/<cat>/<name>/SKILL.md').read_text(),
898 re.MULTILINE)
899 assert len(m.group(1)) <= 60, len(m.group(1))
900```
901 
9022. **Tools referenced in SKILL.md prose must be native Hermes tools or
903 MCP servers the skill explicitly expects.** When the skill needs a
904 capability, point at the proper tool by name in backticks
905 (`` `terminal` ``, `` `web_extract` ``, `` `read_file` ``,
906 `` `patch` ``, `` `search_files` ``, `` `vision_analyze` ``,
907 `` `browser_navigate` ``, `` `delegate_task` ``, etc.). Do NOT
908 name shell utilities the agent already has wrapped — `grep` →
909 `search_files`, `cat`/`head`/`tail` → `read_file`, `sed`/`awk` →
910 `patch`, `find`/`ls` → `search_files target='files'`. If the skill
911 depends on an MCP server, name the MCP server and document the
912 expected setup in `## Prerequisites`. Anything else (third-party
913 CLIs, shell pipelines, etc.) is fair game inside script files but
914 should not be the headline interaction surface in the prose.
915 
9163. **`platforms:` gating audited against actual script imports.**
917 Skills that use POSIX-only primitives (`fcntl`, `termios`,
918 `os.setsid`, `os.kill(pid, 0)` for liveness, `/proc`, `/tmp`
919 hardcoded, `signal.SIGKILL`, bash heredocs, `osascript`, `apt`,
920 `systemctl`) must declare their supported platforms. Default
921 posture: try to fix it cross-platform first — `tempfile.gettempdir`,
922 `pathlib.Path`, `psutil.pid_exists`, Python-level filtering instead
923 of `grep`. Gate to a narrower set only when the dependency is
924 genuinely platform-bound.
925 
9264. **`author` credits the human contributor first.** For external
927 contributions, the contributor's real name + GitHub handle goes
928 first; "Hermes Agent" is the secondary collaborator. If the
929 contributor's commit shows "Hermes Agent" as author (because they
930 used Hermes to draft the skill), replace it with their actual name
931 — credit the human, not the tool.
932 
9335. **SKILL.md body uses the modern section order.** `# <Skill> Skill`
934 title, 2-3 sentence intro stating what it does and doesn't do,
935 `## When to Use`, `## Prerequisites`, `## How to Run`,
936 `## Quick Reference`, `## Procedure`, `## Pitfalls`,
937 `## Verification`. Target ~200 lines for a complex skill,
938 ~100 lines for a simple one. Cut redundant intro fluff, marketing
939 prose, and re-explanations of env vars already in
940 `## Prerequisites`.
941 
9426. **Scripts go in `scripts/`, references in `references/`,
943 templates in `templates/`.** Don't expect the model to inline-write
944 parsers, XML walkers, or non-trivial logic every call — ship a
945 helper script. Reference it from SKILL.md by path relative to the
946 skill directory.
947 
9487. **Tests live at `tests/skills/test_<skill>_skill.py`** and use only
949 stdlib + pytest + `unittest.mock`. No live network calls. Run via
950 `scripts/run_tests.sh tests/skills/test_<skill>_skill.py -q`.
951 
9528. **`.env.example` additions are isolated to a clearly delimited
953 block.** Don't touch the surrounding file — contributor-supplied
954 `.env.example` versions are usually stale and edits outside the
955 skill's own block must be dropped during salvage.
956 
957The full salvage / modernization checklist for external skill PRs
958lives in the `hermes-agent-dev` skill at
959`references/new-skill-pr-salvage.md` — load it before polishing
960contributor skill PRs.
961 
962---
963 
964## Toolsets
965 
966All toolsets are defined in `toolsets.py` as a single `TOOLSETS` dict.
967Each platform's adapter picks a base toolset (e.g. Telegram uses
968`"messaging"`); `_HERMES_CORE_TOOLS` is the default bundle most
969platforms inherit from.
970 
971Current toolset keys: `browser`, `clarify`, `code_execution`, `cronjob`,
972`debugging`, `delegation`, `discord`, `discord_admin`, `feishu_doc`,
973`feishu_drive`, `file`, `homeassistant`, `image_gen`, `kanban`, `memory`,
974`messaging`, `moa`, `rl`, `safe`, `search`, `session_search`, `skills`,
975`spotify`, `terminal`, `todo`, `tts`, `video`, `vision`, `web`, `yuanbao`.
976 
977Enable/disable per platform via `hermes tools` (the curses UI) or the
978`tools.<platform>.enabled` / `tools.<platform>.disabled` lists in
979`config.yaml`.
980 
981---
982 
983## Delegation (`delegate_task`)
984 
985`tools/delegate_tool.py` spawns a subagent with an isolated
986context + terminal session. By default the parent waits for the
987child's summary before continuing its own loop. With `background=true`,
988Hermes returns a delegation id immediately and the result re-enters the
989conversation later through the async-delegation completion queue.
990 
991Two shapes:
992 
993- **Single:** pass `goal` (+ optional `context`, `toolsets`).
994- **Batch (parallel):** pass `tasks: [...]` — each gets its own subagent
995 running concurrently. Concurrency is capped by
996 `delegation.max_concurrent_children` (default 3).
997 
998Roles:
999 
1000- `role="leaf"` (default) — focused worker. Cannot call `delegate_task`,
1001 `clarify`, `memory`, `send_message`, `cronjob`. Retains `execute_code`
1002 (programmatic tool calling).
1003- `role="orchestrator"` — retains `delegate_task` so it can spawn its
1004 own workers. Gated by `delegation.orchestrator_enabled` (default true)
1005 and bounded by `delegation.max_spawn_depth` (default 2).
1006 
1007Key config knobs (under `delegation:` in `config.yaml`):
1008`max_concurrent_children`, `max_spawn_depth`, `child_timeout_seconds`,
1009`orchestrator_enabled`, `subagent_auto_approve`, `inherit_mcp_toolsets`,
1010`max_iterations`.
1011 
1012Durability rule: background `delegate_task` is detached from the current
1013turn but still process-local. For work that must survive process restart, use
1014`cronjob` or `terminal(background=True, notify_on_complete=True)` instead.
1015 
1016---
1017 
1018## Curator (skill lifecycle)
1019 
1020Background skill-maintenance system that tracks usage on agent-created
1021skills and auto-archives stale ones. Users never lose skills; archives
1022go to `~/.hermes/skills/.archive/` and are restorable.
1023 
1024- **Core:** `agent/curator.py` (review loop, auto-transitions, LLM review
1025 prompt) + `agent/curator_backup.py` (pre-run tar.gz snapshots).
1026- **CLI:** `hermes_cli/curator.py` wires `hermes curator <verb>` where
1027 verbs are: `status`, `run`, `pause`, `resume`, `pin`, `unpin`,
1028 `archive`, `restore`, `prune`, `backup`, `rollback`.
1029- **Telemetry:** `tools/skill_usage.py` owns the sidecar
1030 `~/.hermes/skills/.usage.json` — per-skill `use_count`, `view_count`,
1031 `patch_count`, `last_activity_at`, `state` (active / stale /
1032 archived), `pinned`.
1033 
1034Invariants:
1035- Curator only touches skills with `created_by: "agent"` provenance —
1036 bundled + hub-installed skills are off-limits.
1037- Never deletes; max destructive action is archive.
1038- Pinned skills are exempt from every auto-transition and from the
1039 LLM review pass.
1040- `skill_manage(action="delete")` refuses pinned skills; patch/edit/
1041 write_file/remove_file go through so the agent can keep improving
1042 pinned skills.
1043 
1044Config section (`curator:` in `config.yaml`):
1045`enabled`, `interval_hours`, `min_idle_hours`, `stale_after_days`,
1046`archive_after_days`, `backup.*`.
1047 
1048Full user-facing docs: `website/docs/user-guide/features/curator.md`.
1049 
1050---
1051 
1052## Cron (scheduled jobs)
1053 
1054`cron/jobs.py` (job store) + `cron/scheduler.py` (tick loop). Agents
1055schedule jobs via the `cronjob` tool; users via `hermes cron <verb>`
1056(`list`, `add`, `edit`, `pause`, `resume`, `run`, `remove`) or the
1057`/cron` slash command.
1058 
1059Supported schedule formats:
1060- Duration: `"30m"`, `"2h"`, `"1d"`
1061- "every" phrase: `"every 2h"`, `"every monday 9am"`
1062- 5-field cron expression: `"0 9 * * *"`
1063- ISO timestamp (one-shot): `"2026-06-01T09:00:00Z"`
1064 
1065Per-job fields include `skills` (load specific skills), `model` /
1066`provider` overrides, `script` (pre-run data-collection script whose
1067stdout is injected into the prompt; `no_agent=True` turns the script
1068into the entire job), `context_from` (chain job A's last output into
1069job B's prompt), `workdir` (run in a specific directory with its
1070`AGENTS.md`/`CLAUDE.md` loaded), and multi-platform delivery.
1071 
1072Hardening invariants:
1073- **3-minute hard interrupt** on cron sessions — runaway agent loops
1074 cannot monopolize the scheduler.
1075- Catchup window: half the job's period, clamped to 120s–2h.
1076- Grace window: 120s for one-shot jobs whose fire time was missed.
1077- File lock at `~/.hermes/cron/.tick.lock` prevents duplicate ticks
1078 across processes.
1079- Cron sessions pass `skip_memory=True` by default; memory providers
1080 intentionally do not run during cron.
1081 
1082Cron deliveries are **not** mirrored into the target gateway session —
1083they land in their own cron session with a header/footer frame so the
1084main conversation's message-role alternation stays intact.
1085 
1086---
1087 
1088## Kanban (multi-agent work queue)
1089 
1090Durable SQLite-backed board that lets multiple profiles / workers
1091collaborate on shared tasks. Users drive it via `hermes kanban <verb>`;
1092workers spawned by the dispatcher drive it via a dedicated `kanban_*`
1093toolset so their schema footprint is zero when they're not inside a
1094kanban task.
1095 
1096- **CLI:** `hermes_cli/kanban.py` wires `hermes kanban` with verbs
1097 `init`, `create`, `list` (alias `ls`), `show`, `assign`, `link`,
1098 `unlink`, `comment`, `attach`, `attachments`, `attach-rm`, `complete`,
1099 `block`, `unblock`, `archive`, `tail`, plus less-commonly-used `watch`,
1100 `stats`, `runs`, `log`, `assignees`, `heartbeat`, `notify-*`,
1101 `dispatch`, `daemon`, `gc`.
1102- **Worker/orchestrator toolset:** `tools/kanban_tools.py` exposes
1103 `kanban_show`, `kanban_complete`, `kanban_block`, `kanban_heartbeat`,
1104 `kanban_comment`, `kanban_create`, `kanban_link`, `kanban_attach`,
1105 `kanban_attach_url`, `kanban_attachments`; profiles that explicitly
1106 enable the `kanban` toolset outside a dispatcher-spawned task also get
1107 `kanban_list` and `kanban_unblock` for board routing.
1108- **Dispatcher:** long-lived loop that (default every 60s) reclaims
1109 stale claims, promotes ready tasks, atomically claims, and spawns
1110 assigned profiles. Runs **inside the gateway** by default via
1111 `kanban.dispatch_in_gateway: true`.
1112- **Plugin assets:** `plugins/kanban/dashboard/` (web UI) +
1113 `plugins/kanban/systemd/` (`hermes-kanban-dispatcher.service` for
1114 standalone dispatcher deployment).
1115 
1116Isolation model:
1117- **Board** is the hard boundary — workers are spawned with
1118 `HERMES_KANBAN_BOARD` pinned in their env so they can't see other
1119 boards.
1120- **Tenant** is a soft namespace *within* a board — one specialist
1121 fleet can serve multiple businesses with workspace-path + memory-key
1122 isolation.
1123- After `kanban.failure_limit` consecutive non-success attempts on the
1124 same task (default: 2), the dispatcher auto-blocks it to prevent spin
1125 loops.
1126 
1127Full user-facing docs: `website/docs/user-guide/features/kanban.md`.
1128 
1129---
1130 
1131## Important Policies
1132 
1133### Prompt Caching Must Not Break
1134 
1135Hermes-Agent ensures caching remains valid throughout a conversation. **Do NOT implement changes that would:**
1136- Alter past context mid-conversation
1137- Change toolsets mid-conversation
1138- Reload memories or rebuild system prompts mid-conversation
1139 
1140Cache-breaking forces dramatically higher costs. The ONLY time we alter context is during context compression.
1141 
1142Slash commands that mutate system-prompt state (skills, tools, memory, etc.)
1143must be **cache-aware**: default to deferred invalidation (change takes
1144effect next session), with an opt-in `--now` flag for immediate
1145invalidation. See `/skills install --now` for the canonical pattern.
1146 
1147### Background Process Notifications (Gateway)
1148 
1149When `terminal(background=true, notify_on_complete=true)` is used, the gateway runs a watcher that
1150detects process completion and triggers a new agent turn. Control verbosity of background process
1151messages with `display.background_process_notifications`
1152in config.yaml (or `HERMES_BACKGROUND_NOTIFICATIONS` env var):
1153 
1154- `all` — running-output updates + final message (default)
1155- `result` — only the final completion message
1156- `error` — only the final message when exit code != 0
1157- `off` — no watcher messages at all
1158 
1159---
1160 
1161## Profiles: Multi-Instance Support
1162 
1163Hermes supports **profiles** — multiple fully isolated instances, each with its own
1164`HERMES_HOME` directory (config, API keys, memory, sessions, skills, gateway, etc.).
1165 
1166The core mechanism: `_apply_profile_override()` in `hermes_cli/main.py` sets
1167`HERMES_HOME` before any module imports. All `get_hermes_home()` references
1168automatically scope to the active profile.
1169 
1170### Rules for profile-safe code
1171 
11721. **Use `get_hermes_home()` for all HERMES_HOME paths.** Import from `hermes_constants`.
1173 NEVER hardcode `~/.hermes` or `Path.home() / ".hermes"` in code that reads/writes state.
1174```python
1175 # GOOD
1176 from hermes_constants import get_hermes_home
1177 config_path = get_hermes_home() / "config.yaml"
1178 
1179 # BAD — breaks profiles
1180 config_path = Path.home() / ".hermes" / "config.yaml"
1181```
1182 
11832. **Use `display_hermes_home()` for user-facing messages.** Import from `hermes_constants`.
1184 This returns `~/.hermes` for default or `~/.hermes/profiles/<name>` for profiles.
1185```python
1186 # GOOD
1187 from hermes_constants import display_hermes_home
1188 print(f"Config saved to {display_hermes_home()}/config.yaml")
1189 
1190 # BAD — shows wrong path for profiles
1191 print("Config saved to ~/.hermes/config.yaml")
1192```
1193 
11943. **Module-level constants are fine** — they cache `get_hermes_home()` at import time,
1195 which is AFTER `_apply_profile_override()` sets the env var. Just use `get_hermes_home()`,
1196 not `Path.home() / ".hermes"`.
1197 
11984. **Tests that mock `Path.home()` must also set `HERMES_HOME`** — since code now uses
1199 `get_hermes_home()` (reads env var), not `Path.home() / ".hermes"`:
1200```python
1201 with patch.object(Path, "home", return_value=tmp_path), \
1202 patch.dict(os.environ, {"HERMES_HOME": str(tmp_path / ".hermes")}):
1203 ...
1204```
1205 
12065. **Gateway platform adapters should use token locks** — if the adapter connects with
1207 a unique credential (bot token, API key), call `acquire_scoped_lock()` from
1208 `gateway.status` in the `connect()`/`start()` method and `release_scoped_lock()` in
1209 `disconnect()`/`stop()`. This prevents two profiles from using the same credential.
1210 See `plugins/platforms/irc/adapter.py` for the canonical pattern.
1211 
12126. **Profile operations are HOME-anchored, not HERMES_HOME-anchored** — `_get_profiles_root()`
1213 returns `Path.home() / ".hermes" / "profiles"`, NOT `get_hermes_home() / "profiles"`.
1214 This is intentional — it lets `hermes -p coder profile list` see all profiles regardless
1215 of which one is active.
1216 
1217## Known Pitfalls
1218 
1219### DO NOT hardcode `~/.hermes` paths
1220Use `get_hermes_home()` from `hermes_constants` for code paths. Use `display_hermes_home()`
1221for user-facing print/log messages. Hardcoding `~/.hermes` breaks profiles — each profile
1222has its own `HERMES_HOME` directory. This was the source of 5 bugs fixed in PR #3575.
1223 
1224### DO NOT introduce new `simple_term_menu` usage
1225Existing call sites in `hermes_cli/main.py` remain for legacy fallback only;
1226the preferred UI is curses (stdlib) because `simple_term_menu` has
1227ghost-duplication rendering bugs in tmux/iTerm2 with arrow keys. New
1228interactive menus must use `hermes_cli/curses_ui.py` — see
1229`hermes_cli/tools_config.py` for the canonical pattern.
1230 
1231### DO NOT use `\033[K` (ANSI erase-to-EOL) in spinner/display code
1232Leaks as literal `?[K` text under `prompt_toolkit`'s `patch_stdout`. Use space-padding: `f"\r{line}{' ' * pad}"`.
1233 
1234### `_last_resolved_tool_names` is a process-global in `model_tools.py`
1235`_run_single_child()` in `delegate_tool.py` saves and restores this global around subagent execution. If you add new code that reads this global, be aware it may be temporarily stale during child agent runs.
1236 
1237### DO NOT hardcode cross-tool references in schema descriptions
1238Tool schema descriptions must not mention tools from other toolsets by name (e.g., `browser_navigate` saying "prefer web_search"). Those tools may be unavailable (missing API keys, disabled toolset), causing the model to hallucinate calls to non-existent tools. If a cross-reference is needed, add it dynamically in `get_tool_definitions()` in `model_tools.py` — see the `browser_navigate` / `execute_code` post-processing blocks for the pattern.
1239 
1240### The gateway has TWO message guards — both must bypass approval/control commands
1241When an agent is running, messages pass through two sequential guards:
1242(1) **base adapter** (`gateway/platforms/base.py`) queues messages in
1243`_pending_messages` when `session_key in self._active_sessions`, and
1244(2) **gateway runner** (`gateway/run.py`) intercepts `/stop`, `/new`,
1245`/queue`, `/status`, `/approve`, `/deny` before they reach
1246`running_agent.interrupt()`. Any new command that must reach the runner
1247while the agent is blocked (e.g. approval prompts) MUST bypass BOTH
1248guards and be dispatched inline, not via `_process_message_background()`
1249(which races session lifecycle).
1250 
1251### Squash merges from stale branches silently revert recent fixes
1252Before squash-merging a PR, ensure the branch is up to date with `main`
1253(`git fetch origin main && git reset --hard origin/main` in the worktree,
1254then re-apply the PR's commits). A stale branch's version of an unrelated
1255file will silently overwrite recent fixes on main when squashed. Verify
1256with `git diff HEAD~1..HEAD` after merging — unexpected deletions are a
1257red flag.
1258 
1259### Don't wire in dead code without E2E validation
1260Unused code that was never shipped was dead for a reason. Before wiring an
1261unused module into a live code path, E2E test the real resolution chain
1262with actual imports (not mocks) against a temp `HERMES_HOME`.
1263 
1264### Tests must not write to `~/.hermes/`
1265The `_isolate_hermes_home` autouse fixture in `tests/conftest.py` redirects `HERMES_HOME` to a temp dir. Never hardcode `~/.hermes/` paths in tests.
1266 
1267**Profile tests**: When testing profile features, also mock `Path.home()` so that
1268`_get_profiles_root()` and `_get_default_hermes_home()` resolve within the temp dir.
1269Use the pattern from `tests/hermes_cli/test_profiles.py`:
1270```python
1271@pytest.fixture
1272def profile_env(tmp_path, monkeypatch):
1273 home = tmp_path / ".hermes"
1274 home.mkdir()
1275 monkeypatch.setattr(Path, "home", lambda: tmp_path)
1276 monkeypatch.setenv("HERMES_HOME", str(home))
1277 return home
1278```
1279 
1280---
1281 
1282## Testing
1283 
1284### Python
1285**ALWAYS use `scripts/run_tests.sh`** — do not call `pytest` directly. The script enforces
1286hermetic environment parity with CI (unset credential vars, TZ=UTC, LANG=C.UTF-8,
1287per-file subprocess isolation via `scripts/run_tests_parallel.py` — no xdist,
1288worker count auto-scaled from CPU count). Direct `pytest`
1289on a 16+ core developer machine with API keys set diverges from CI in ways
1290that have caused multiple "works locally, fails in CI" incidents (and the reverse).
1291 
1292```bash
1293scripts/run_tests.sh # full suite, CI-parity
1294scripts/run_tests.sh tests/gateway/ # one directory
1295scripts/run_tests.sh tests/agent/test_foo.py -k test_x # one test (file + -k; the runner is file-granular)
1296scripts/run_tests.sh -v --tb=long # pass-through pytest flags
1297```
1298 
1299**Flake policy:** the runner auto-retries a failing test FILE once in a fresh
1300subprocess (`--file-retries`, default 1; `HERMES_TEST_FILE_RETRIES=0` to
1301disable). Pass-on-retry counts as green but is printed in a `⚠ FLAKY` summary
1302section with both attempts' output. A FLAKY report is a bug to fix, not noise
1303to ignore — timing-sensitive tests must not assume a quiet runner (loose
1304wall-clock bounds ≥ 2s, event-based sync, no `assert not _wait_until(...)`
1305negative-timing races).
1306 
1307#### Subprocess-per-test-file isolation
1308 
1309Every test file runs in a freshly-spawned Python subprocess via `run_tests_parallel.py`. This means module-level dicts/sets and
1310ContextVars from one test file cannot leak into the next.
1311 
1312#### Why the wrapper
1313 
1314| | Without wrapper | With wrapper |
1315| ------------------- | ------------------------------------------- | ----------------------------------------- |
1316| Provider API keys | Whatever is in your env (auto-detects pool) | All env vars except a specific few unset. |
1317| HOME / `~/.hermes/` | Your real config+auth.json | Temp dir per test |
1318| Timezone | Local TZ (PDT etc.) | UTC |
1319| Locale | Whatever is set | C.UTF-8 |
1320 
1321### Where to place what tests
1322 
1323The CI change classifier (`scripts/ci/classify_changes.py`) runs specific jobs based on what files changed. A Python test that asserts
1324about the contents of `package.json`, `package-lock.json`, `.ts`/`.tsx`
1325source, or any other JS-side artifact will not run on a PR that only touches
1326those files. This means a regression can go green on a PR and red on `main` (where the
1327classifier fails open and runs everything).
1328 
1329Any test that reads or asserts about `package.json`,
1330`package-lock.json`, `tsconfig.json`, `.ts`/`.tsx`/`.js`/`.mjs`/`.cjs`
1331source files configuration belongs in the JS (vitest) test suite, not in `tests/*.py`.
1332 
1333### Don't write change-detector tests
1334 
1335A test is a **change-detector** if it fails whenever data that is **expected
1336to change** gets updated — model catalogs, config version numbers,
1337enumeration counts, hardcoded lists of provider models. These tests add no
1338behavioral coverage; they just guarantee that routine source updates break
1339CI and cost engineering time to "fix."
1340 
1341**Do not write:**
1342 
1343```python
1344# catalog snapshot — breaks every model release
1345assert "gemini-2.5-pro" in _PROVIDER_MODELS["gemini"]
1346assert "MiniMax-M2.7" in models
1347 
1348# config version literal — breaks every schema bump
1349assert DEFAULT_CONFIG["_config_version"] == 21
1350 
1351# enumeration count — breaks every time a skill/provider is added
1352assert len(_PROVIDER_MODELS["huggingface"]) == 8
1353```
1354 
1355**Do write:**
1356 
1357```python
1358# behavior: does the catalog plumbing work at all?
1359assert "gemini" in _PROVIDER_MODELS
1360assert len(_PROVIDER_MODELS["gemini"]) >= 1
1361 
1362# behavior: does migration bump the user's version to current latest?
1363assert raw["_config_version"] == DEFAULT_CONFIG["_config_version"]
1364 
1365# invariant: no plan-only model leaks into the legacy list
1366assert not (set(moonshot_models) & coding_plan_only_models)
1367 
1368# invariant: every model in the catalog has a context-length entry
1369for m in _PROVIDER_MODELS["huggingface"]:
1370 assert m.lower() in DEFAULT_CONTEXT_LENGTHS_LOWER
1371```
1372 
1373The rule: if the test reads like a snapshot of current data, delete it. If
1374it reads like a contract about how two pieces of data must relate, keep it.
1375When a PR adds a new provider/model and you want a test, make the test
1376assert the relationship (e.g. "catalog entries all have context lengths"),
1377not the specific names.
1378 
1379Reviewers should reject new change-detector tests; authors should convert
1380them into invariants before re-requesting review.
1381 
1382### Never read source code in tests
1383 
1384A test that reads a source file's text is testing *the shape of the
1385source code*, not its behavior. This is a hard antipattern, banned outright.
1386Any test that reads a .py, .ts, .tsx, etc., file is suspect.
1387 
1388**Why it's actively harmful, not just weak:**
1389 
1390- It passes when the implementation is subtly broken (the regex matches a
1391 call site that exists but is wired wrong) and fails when a correct
1392 refactor changes formatting, variable names, or control flow with
1393 identical runtime behavior. Both directions of failure are wrong.
1394- It can't be run against a built/bundled/minified artifact, so it silently
1395 stops testing anything the moment code moves, gets renamed, or a
1396 dependency reformats it.
1397- It actively blocks refactors: reviewers see "keeps a pattern intact" tests
1398 fail during pure structural cleanup with no behavior change, and either
1399 hand-wave the failure (dangerous) or waste time updating regexes that add
1400 nothing (waste).
1401- It gives false confidence. a green suite full of source-regex tests
1402 looks like coverage but has never once executed the code path it claims
1403 to guard.
1404 
1405**Do not write:**
1406 
1407```ts
1408const source = fs.readFileSync(path.join(__dirname, 'main.ts'), 'utf8')
1409 
1410test('backend spawn hides the Windows console', () => {
1411 assert.match(source, /spawn\(\s*backend\.command,\s*backend\.args[\s\S]{0,300}hiddenWindowsChildOptions/)
1412})
1413```
1414 
1415**Do write — extract the logic into a small pure/DI-testable function and
1416call it for real:**
1417 
1418```ts
1419// backend-spawn.ts
1420export function hiddenWindowsChildOptions(options: SpawnOptionsLike = {}, isWindows = process.platform === 'win32') {
1421 if (!isWindows || 'windowsHide' in options) return options
1422 return { ...options, windowsHide: true }
1423}
1424 
1425// backend-spawn.test.ts
1426test('windowsHide defaults to true on Windows, is left alone elsewhere', () => {
1427 assert.equal(hiddenWindowsChildOptions({}, true).windowsHide, true)
1428 assert.equal(hiddenWindowsChildOptions({}, false).windowsHide, undefined)
1429 assert.equal(hiddenWindowsChildOptions({ windowsHide: false }, true).windowsHide, false)
1430})
1431```
1432 
1433If the logic lives inline in a god-file (`main.ts`, `cli.py`,
1434`gateway/run.py`) and extracting it feels disruptive: that's the actual
1435signal to do the extraction, not to regex around it.
1436 

Commands it names

  • npm install
  • npm run dev
  • npm start
  • npm run build
  • npm run typecheck
  • npm run lint
  • npm run fmt
  • npm test
  • git log -p -S
  • git log -p -S "<symbol>"
  • npx vitest run src/lib/desktop-slash-commands.test.ts
  • git+https://...@<40-char-sha>
  • uv lock
  • uv.lock
  • git fetch origin main && git reset --hard origin/main
  • git diff HEAD~1..HEAD
  • pytest

Sections

  • Hermes Agent - Development Guide
  • What Hermes Is
  • Contribution Rubric — What We Want / What We Don't
  • What we want
  • What we don't want (rejected even when well-built)
  • Before you call it a bug — verify the premise (and when NOT to close)
  • The Footprint Ladder (new capability decision)
  • Development Environment
  • Prefer .venv; fall back to venv if that's what your checkout has.
  • Project Structure
  • TypeScript Style
  • File Dependency Chain
  • AIAgent Class (run_agent.py)
  • Agent Loop
  • CLI Architecture (cli.py)
  • Slash Command Registry (`hermes_cli/commands.py`)
  • Adding a Slash Command
  • TUI Architecture (ui-tui + tui_gateway)
  • Process Model
  • Transport
  • Key Surfaces
  • Slash Command Flow
  • Dev Commands
  • TUI in the Dashboard (`hermes dashboard` → `/chat`)
  • Electron Desktop Chat App (`apps/desktop/`)
  • Adding New Tools
  • Dependency Pinning Policy
  • Adding Configuration
  • config.yaml options:
  • Top-level `config.yaml` sections (non-exhaustive):
  • .env variables (SECRETS ONLY — API keys, tokens, passwords):
  • Config loaders (three paths — know which one you're in):
  • Working directory:
  • Skin/Theme System
  • Architecture
  • What skins customize
  • Built-in skins
  • Adding a built-in skin
  • User skins (YAML)
  • Plugins
  • General plugins (`hermes_cli/plugins.py` + `plugins/<name>/`)
  • Memory-provider plugins (`plugins/memory/<name>/`)
  • Model-provider plugins (`plugins/model-providers/<name>/`)
  • Dashboard / context-engine / image-gen plugin directories
  • Skills
  • SKILL.md frontmatter
  • Skill authoring standards (HARDLINE)
  • Toolsets
  • Delegation (`delegate_task`)
  • Curator (skill lifecycle)
  • Cron (scheduled jobs)
  • Kanban (multi-agent work queue)
  • Important Policies
  • Prompt Caching Must Not Break
  • Background Process Notifications (Gateway)
  • Profiles: Multi-Instance Support
  • Rules for profile-safe code
  • Known Pitfalls
  • DO NOT hardcode `~/.hermes` paths
  • DO NOT introduce new `simple_term_menu` usage

What it covers

setupbuildtestlint-formatcode-stylearchitecturetypestesting-strategysecurityapiuiperformancemonorepodo-notagent-behaviour

Stack — with the evidence

python

(1.00)

node

(1.00)

docker

(1.00)

vitest

(0.95)

ai-agent

(0.90)

react

(0.70)

express

(0.70)

fastapi

(0.70)

tailwind

(0.70)

vite

(0.70)

playwright

(0.70)

desktop-app

(0.70)

typescript

(0.60)

javascript

(0.60)

eslint

(0.60)

github-actions

(0.60)

Format

AGENTS.md

A plain-markdown README for coding agents, deliberately unopinionated: no frontmatter, no globs, no vendor keys. That minimalism is why it became the one file a dozen different agents will read, and why it carries the least per-file targeting power of any format here.

What the corpus says about it

Repository

Owner
NousResearch
Language
—
License
—
Archived
no

All configs in this repo

Also in NousResearch/hermes-agent

Diff this repo’s formats

One repository carrying more than one format is the comparison this product exists for: does anyone actually write different content in each file, or is one a copy of the other?

The other instruction files in this repository
RepositoryFormatStackCoversScoreChanged
NousResearch/hermes-agentapps/desktop/AGENTS.md · 225kAGENTS.mdtypescriptpython+14testsecurityagent-behaviour44/1003 days ago
Diff against apps/desktop/AGENTS.md

Similar configs

Same format, overlapping stack, ranked by quality.

Same format, overlapping stack, ranked by quality
RepositoryFormatStackCoversScoreChanged
n8n-io/n8npackages/@n8n/agents/AGENTS.md · 199kAGENTS.mdtypescriptlangchain+16buildteststylearch+3100/1003 days ago
OnlyTerp/prompt-cache-skillsAGENTS.md · 112AGENTS.mdpythongithub-actionssetupbuildtestlint-format+5100/1003 days ago
wpscanteam/wpscanAGENTS.md · 9.7kAGENTS.mdrubyvue+3setupbuildteststyle+6100/1002 days ago
mui/material-uiAGENTS.md · 99kAGENTS.mdtypescriptjavascript+13setupbuildtestlint-format+9100/1003 days ago
SkeneTechnologies/skene-cookbookAGENTS.md · 51AGENTS.mdpythoneslint+4setupbuildtestlint-format+7100/1002 days ago
duckduckgo/content-scope-scriptsspecial-pages/AGENTS.md · 70AGENTS.mdtypescriptjavascript+5buildteststylearch+3100/1003 days ago
trick77/agents-md-syncAGENTS.md · 2AGENTS.mdtypescriptnode+4setupbuildteststyle+5100/1003 days ago
aaif-goose/gooseAGENTS.md · 52kAGENTS.mdrusttypescript+2setupbuildtestlint-format+6100/1003 days ago
RuleStack

Built by

Kynth Studio

Directory

Configs
Stacks
Compare formats
Diff two configs
Best AGENTS.md examples

Formats

AGENTS.md
CLAUDE.md
Cursor rules
Copilot instructions

Reference

Read API
Corpus health
Privacy Policy
Terms

RuleStack

RuleStack

Built by

Kynth Studio

Directory

Configs
Stacks
Compare formats
Diff two configs
Best AGENTS.md examples

Formats

AGENTS.md
CLAUDE.md
Cursor rules
Copilot instructions

Reference

Read API
Corpus health
Privacy Policy
Terms

RuleStack

RuleStack

Built by

Kynth Studio

Directory

Configs
Stacks
Compare formats
Diff two configs
Best AGENTS.md examples

Formats

AGENTS.md
CLAUDE.md
Cursor rules
Copilot instructions

Reference

Read API
Corpus health
Privacy Policy
Terms

RuleStack