Thank you for contributing to Hermes Agent! This guide covers everything you need: setting up your dev environment, understanding the architecture, deciding what to build, and getting your PR merged.
We value contributions in this order:
A quick search before you build saves your time and keeps the PR queue clean — duplicates are common here, so it's worth a minute up front.
bash
gh search issues --repo NousResearch/hermes-agent "<your terms>"
gh search prs --repo NousResearch/hermes-agent --state all "<your terms>"
Or use the web UI: issues · PRs (all states).search_files, or your editor's grep) for the capability before proposing it.Related: #38284 covers the agent-side analog — Hermes itself checking existing issues and PRs before deep self-troubleshooting. This section is the human-contributor complement.
This is the most common question for new contributors. The answer is almost always skill.
terminal or web_extractBundled skills (in skills/) ship with every Hermes install. They should be broadly useful to most users:
If your skill is official and useful but not universally needed (e.g., a paid service integration, a heavyweight dependency), put it in optional-skills/ — it ships with the repo but isn't activated by default. Users can discover it via hermes skills browse (labeled "official") and install it with hermes skills install (no third-party warning, built-in trust).
If your skill is specialized, community-contributed, or niche, it's better suited for a Skills Hub — upload it to a skills registry and share it in the Nous Research Discord. Users can install it with hermes skills install.
We are no longer accepting new memory providers into this repo. The set of built-in providers under plugins/memory/ (honcho, mem0, supermemory, byterover, hindsight, holographic, openviking, retaindb) is closed. If you want to add a new memory backend, publish it as a standalone plugin repo that users install into ~/.hermes/plugins/ (or via a pip entry point).
Standalone memory plugins:
MemoryProvider ABC (agent/memory_provider.py) — sync_turn, prefetch, shutdown, and optionally post_setup(hermes_home, config) for setup-wizard integrationdiscover_memory_providers() picks them up from user/project plugin directories and pip entry pointshermes memory setup via post_setup() — no need to touch core coderegister_cli(subparser) in a cli.py filePRs that add a new directory under plugins/memory/ will be closed with a pointer to publish the provider as its own repo. Existing in-tree providers stay; bug fixes to them are welcome.
This isn't a quality bar — it's a coupling-and-maintenance decision. Memory providers are the most common plugin type and they shouldn't all live in this tree.
The same rule extends to any plugin that integrates someone else's product or project — observability/metrics backends, vendor SaaS connectors, analytics dashboards, paid-service tie-ins, and similar third-party integrations. These do not land in this repo.
The reason is maintenance load, not quality. Every external product absorbed into the core tree becomes ours to keep working against a fast-moving codebase, for a backend we don't own and can't control. Hermes ships a lot and the core moves quickly; coupling third-party products into it creates an open-ended burden on the maintainers.
Publish these as a standalone plugin repo instead:
~/.hermes/plugins/, project .hermes/plugins/, or a pip entry point) — see Build a Hermes Pluginpre_tool_call, post_tool_call, pre_llm_call, post_llm_call, on_session_start, on_session_end), tools (ctx.register_tool), and CLI subcommands (ctx.register_cli_command) through the surface we already expose — no core changes neededctx method) — never special-case your plugin in core#plugins-skills-and-skins channel so users can find and install itA well-built third-party-product plugin can clear automated review and still be closed for this reason — it's a placement decision, not a verdict on the code. PRs that add such a directory under plugins/ will be closed with a pointer to publish it as its own repo.
| Requirement | Notes |
|---|---|
| Git | With the git-lfs extension installed |
| Python 3.11–3.13 | uv will install it if missing |
| uv | Fast Python package manager (install) |
| Node.js 20+ | Optional — needed for browser tools and WhatsApp bridge (matches root package.json engines) |
For most contributors, the best development bootstrap is the same path users
take: run the standard installer, then work inside the repository it cloned.
The installer creates the Hermes venv, wires the hermes command, stamps the
install method for hermes update, and clones the full git project into
$HERMES_HOME/hermes-agent (usually ~/.hermes/hermes-agent). That keeps your
development environment on the same layout the CLI, updater, lazy dependency
installer, gateway, and docs assume.
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
cd "${HERMES_HOME:-$HOME/.hermes}/hermes-agent"
# Add dev/test extras on top of the standard install.
uv pip install -e ".[all,dev]"
# Optional: browser tools / docs site dependencies.
npm install
After that, create branches and run tests from that checkout:
git checkout -b fix/description
scripts/run_tests.sh
Use this only if you intentionally do not want Hermes' managed install layout
(for example, a throwaway clone inside a container or CI job). If you install
this way, make sure you run the hermes entrypoint from this venv; running the
system python3 -m hermes_cli.main can pick up unrelated system Python
packages.
Create the venv outside the cloned source tree. A venv that lives inside
the directory the agent operates from can be wiped by a relative-path command
the agent runs against its own checkout (rm -rf venv, uv venv venv, etc.),
which silently destroys the running runtime mid-session. Keeping it outside the
tree means no relative path from the workspace resolves to it.
git clone https://github.com/NousResearch/hermes-agent.git
cd hermes-agent
# Create venv with Python 3.11, OUTSIDE the source tree
uv venv ~/.hermes/venvs/hermes-dev --python 3.11
export VIRTUAL_ENV="$HOME/.hermes/venvs/hermes-dev"
export PATH="$VIRTUAL_ENV/bin:$PATH"
# Install with all extras (messaging, cron, CLI menus, dev tools)
uv pip install -e ".[all,dev]"
# Optional: browser tools
npm install
mkdir -p ~/.hermes/{cron,sessions,logs,memories,skills}
cp cli-config.yaml.example ~/.hermes/config.yaml
touch ~/.hermes/.env
# Add at minimum an LLM provider key:
echo "OPENROUTER_API_KEY=***" >> ~/.hermes/.env
# The standard installer already put `hermes` on PATH.
hermes doctor
hermes chat -q "Hello"
If you used the manual clone fallback, run ./hermes from the checkout or
symlink this clone's venv explicitly:
mkdir -p ~/.local/bin
ln -sf "$(pwd)/venv/bin/hermes" ~/.local/bin/hermes
# Preferred — matches CI (hermetic `env -i`, per-file subprocess isolation
# via run_tests_parallel.py, worker count auto-scaled); see AGENTS.md
scripts/run_tests.sh
# Alternative (activate the venv first). The wrapper is still recommended
# for parity with GitHub Actions before you open a PR:
pytest tests/ -v
hermes-agent/
├── run_agent.py # AIAgent class — core conversation loop, tool dispatch, session persistence
├── cli.py # HermesCLI class — interactive TUI, prompt_toolkit integration
├── model_tools.py # Tool orchestration (thin layer over tools/registry.py)
├── toolsets.py # Tool groupings and presets (hermes-cli, hermes-telegram, etc.)
├── hermes_state.py # SQLite session database with FTS5 full-text search, session titles
├── batch_runner.py # Parallel batch processing for trajectory generation
│
├── agent/ # Agent internals (extracted modules)
│ ├── prompt_builder.py # System prompt assembly (identity, skills, context files, memory)
│ ├── context_compressor.py # Auto-summarization when approaching context limits
│ ├── auxiliary_client.py # Resolves auxiliary OpenAI clients (summarization, vision)
│ ├── display.py # KawaiiSpinner, tool progress formatting
│ ├── model_metadata.py # Model context lengths, token estimation
│ └── trajectory.py # Trajectory saving helpers
│
├── hermes_cli/ # CLI command implementations
│ ├── main.py # Entry point, argument parsing, command dispatch
│ ├── config.py # Config management, migration, env var definitions
│ ├── setup.py # Interactive setup wizard
│ ├── auth.py # Provider resolution, OAuth, Nous Portal
│ ├── models.py # OpenRouter model selection lists
│ ├── banner.py # Welcome banner, ASCII art
│ ├── commands.py # Central slash command registry (CommandDef), autocomplete, gateway helpers
│ ├── callbacks.py # Interactive callbacks (clarify, sudo, approval)
│ ├── doctor.py # Diagnostics
│ ├── skills_hub.py # Skills Hub CLI + /skills slash command
│ └── skin_engine.py # Skin/theme engine — data-driven CLI visual customization
│
├── tools/ # Tool implementations (self-registering)
│ ├── registry.py # Central tool registry (schemas, handlers, dispatch)
│ ├── approval.py # Dangerous command detection + per-session approval
│ ├── terminal_tool.py # Terminal orchestration (sudo, env lifecycle, backends)
│ ├── file_operations.py # read_file, write_file, search, patch, etc.
│ ├── web_tools.py # web_search, web_extract (Parallel/Firecrawl + Gemini summarization)
│ ├── vision_tools.py # Image analysis via multimodal models
│ ├── delegate_tool.py # Subagent spawning and parallel task execution
│ ├── code_execution_tool.py # Sandboxed Python with RPC tool access
│ ├── session_search_tool.py # Search past conversations with FTS5 + anchored windows
│ ├── cronjob_tools.py # Scheduled task management
│ ├── skill_tools.py # Skill search, load, manage
│ └── environments/ # Terminal execution backends
│ ├── base.py # BaseEnvironment ABC
│ ├── local.py, docker.py, ssh.py, singularity.py, modal.py, daytona.py
│
├── gateway/ # Messaging gateway
│ ├── run.py # GatewayRunner — platform lifecycle, message routing, cron
│ ├── config.py # Platform configuration resolution
│ ├── session.py # Session store, context prompts, reset policies
│ └── platforms/ # Platform adapters
│ ├── telegram.py, discord_adapter.py, slack.py, whatsapp.py
│
├── scripts/ # Installer and bridge scripts
│ ├── install.sh # Linux/macOS installer
│ ├── install.ps1 # Windows PowerShell installer
│ └── whatsapp-bridge/ # Node.js WhatsApp bridge (Baileys)
│
├── skills/ # Bundled skills (copied to ~/.hermes/skills/ on install)
├── optional-skills/ # Official optional skills (discoverable via hub, not activated by default)
├── tests/ # Test suite
├── website/ # Documentation site (hermes-agent.nousresearch.com)
│
├── cli-config.yaml.example # Example configuration (copied to ~/.hermes/config.yaml)
└── AGENTS.md # Development guide for AI coding assistants
~/.hermes/)| Path | Purpose |
|---|---|
~/.hermes/config.yaml |
Settings (model, terminal, toolsets, compression, etc.) |
~/.hermes/.env |
API keys and secrets |
~/.hermes/auth.json |
OAuth credentials (Nous Portal) |
~/.hermes/skills/ |
All active skills (bundled + hub-installed + agent-created) |
~/.hermes/memories/ |
Persistent memory (MEMORY.md, USER.md) |
~/.hermes/state.db |
SQLite session database |
~/.hermes/sessions/ |
Gateway routing index (sessions.json), request-dump breadcrumbs, gateway *.jsonl transcripts, and (optionally) per-session JSON snapshots when sessions.write_json_snapshots: true is set. The per-session snapshots are off by default; state.db is canonical. |
~/.hermes/cron/ |
Scheduled job data |
~/.hermes/whatsapp/session/ |
WhatsApp bridge credentials |
User message → AIAgent._run_agent_loop()
├── Build system prompt (prompt_builder.py)
├── Build API kwargs (model, messages, tools, reasoning config)
├── Call LLM (OpenAI-compatible API)
├── If tool_calls in response:
│ ├── Execute each tool via registry dispatch
│ ├── Add tool results to conversation
│ └── Loop back to LLM call
├── If text response:
│ ├── Persist session to DB
│ └── Return final_response
└── Context compression if approaching token limit
registry.register() at import time. model_tools.py triggers discovery by importing all tool modules.web, terminal, file, browser, etc.) that can be enabled/disabled per platform.hermes_state.py) with full-text search and unique session titles. Per-session JSON snapshots in ~/.hermes/sessions/ were superseded by the SQLite store and are off by default; opt back in with sessions.write_json_snapshots: true if you have external tooling that consumes the JSON files directly.provider_routing in config.yaml controls provider selection (sort by throughput/latency/price, allow/ignore specific providers, data retention policies). These are injected as extra_body.provider in API requests.# increment counter adds nothinglogger.warning()/logger.error() — use exc_info=True for unexpected errors so stack traces appear in logsBefore writing a tool, ask: should this be a skill instead?
Tools self-register with the central registry. Each tool file co-locates its schema, handler, and registration:
"""my_tool — Brief description of what this tool does."""
import json
from tools.registry import registry
def my_tool(param1: str, param2: int = 10, **kwargs) -> str:
"""Handler. Returns a string result (often JSON)."""
result = do_work(param1, param2)
return json.dumps(result)
MY_TOOL_SCHEMA = {
"type": "function",
"function": {
"name": "my_tool",
"description": "What this tool does and when the agent should use it.",
"parameters": {
"type": "object",
"properties": {
"param1": {"type": "string", "description": "What param1 is"},
"param2": {"type": "integer", "description": "What param2 is", "default": 10},
},
"required": ["param1"],
},
},
}
def _check_requirements() -> bool:
"""Return True if this tool's dependencies are available."""
return True
registry.register(
name="my_tool",
toolset="my_toolset",
schema=MY_TOOL_SCHEMA,
handler=lambda args, **kw: my_tool(**args, **kw),
check_fn=_check_requirements,
)
Wire into a toolset (required): Built-in tools are auto-discovered: any
tools/*.py file that contains a top-level registry.register(...) call is
imported by discover_builtin_tools() in tools/registry.py when model_tools
loads. There is no manual import list in model_tools.py to maintain.
You must still add the tool name to the appropriate list in toolsets.py
(for example _HERMES_CORE_TOOLS or a dedicated toolset); otherwise the tool
registers but is never exposed to the agent. If you introduce a new toolset,
add it in toolsets.py and wire it into the relevant platform presets.
See AGENTS.md (section Adding New Tools) for profile-aware paths and
plugin vs core guidance.
Bundled skills live in skills/ organized by category. Official optional skills use the same structure in optional-skills/:
skills/
├── research/
│ └── arxiv/
│ ├── SKILL.md # Required: main instructions
│ └── scripts/ # Optional: helper scripts
│ └── search_arxiv.py
├── productivity/
│ └── ocr-and-documents/
│ ├── SKILL.md
│ ├── scripts/
│ └── references/
└── ...
---
name: my-skill
description: Brief description (shown in skill search results)
version: 1.0.0
author: Your Name
license: MIT
platforms: [macos, linux] # Optional — restrict to specific OS platforms
# Valid: macos, linux, windows
# Omit to load on all platforms (default)
required_environment_variables: # Optional — secure setup-on-load metadata
- name: MY_API_KEY
prompt: API key
help: Where to get it
required_for: full functionality
prerequisites: # Optional legacy runtime requirements
env_vars: [MY_API_KEY] # Backward-compatible alias for required env vars
commands: [curl, jq] # Advisory only; does not hide the skill
metadata:
hermes:
tags: [Category, Subcategory, Keywords]
related_skills: [other-skill-name]
fallback_for_toolsets: [web] # Optional — show only when toolset is unavailable
requires_toolsets: [terminal] # Optional — show only when toolset is available
---
# Skill Title
Brief intro.
## When to Use
Trigger conditions — when should the agent load this skill?
## Prerequisites
Env vars, install steps, MCP setup, API key sourcing.
## How to Run
Canonical invocation through the `terminal` tool.
## Quick Reference
Table of common commands or API calls.
## Procedure
Step-by-step instructions the agent follows.
## Pitfalls
Known failure modes and how to handle them.
## Verification
How the agent confirms it worked.
Skills can declare which OS platforms they support via the platforms frontmatter field. Skills with this field are automatically hidden from the system prompt, skills_list(), and slash commands on incompatible platforms.
platforms: [macos] # macOS only (e.g., iMessage, Apple Reminders)
platforms: [macos, linux] # macOS and Linux
platforms: [windows] # Windows only
If the field is omitted or empty, the skill loads on all platforms (backward compatible). See skills/apple/ for examples of macOS-only skills.
Skills can declare conditions that control when they appear in the system prompt, based on which tools and toolsets are available in the current session. This is primarily used for fallback skills — alternatives that should only be shown when a primary tool is unavailable.
Four fields are supported under metadata.hermes:
metadata:
hermes:
fallback_for_toolsets: [web] # Show ONLY when these toolsets are unavailable
requires_toolsets: [terminal] # Show ONLY when these toolsets are available
fallback_for_tools: [web_search] # Show ONLY when these specific tools are unavailable
requires_tools: [terminal] # Show ONLY when these specific tools are available
Semantics:
- fallback_for_*: The skill is a backup. It is hidden when the listed tools/toolsets are available, and shown when they are unavailable. Use this for free alternatives to premium tools.
- requires_*: The skill needs certain tools to function. It is hidden when the listed tools/toolsets are unavailable. Use this for skills that depend on specific capabilities (e.g., a skill that only makes sense with terminal access).
- If both are specified, both conditions must be satisfied for the skill to appear.
- If neither is specified, the skill is always shown (backward compatible).
Examples:
# DuckDuckGo search — shown when Firecrawl (web toolset) is unavailable
metadata:
hermes:
fallback_for_toolsets: [web]
# Smart home skill — only useful when terminal is available
metadata:
hermes:
requires_toolsets: [terminal]
# Local browser fallback — shown when Browserbase is unavailable
metadata:
hermes:
fallback_for_toolsets: [browser]
The filtering happens at prompt build time in agent/prompt_builder.py. The build_skills_system_prompt() function receives the set of available tools and toolsets from the agent and uses _skill_should_show() to evaluate each skill's conditions.
Skills can declare secure setup-on-load metadata via the required_environment_variables frontmatter field. Missing values do not hide the skill from discovery; they trigger a CLI-only secure prompt when the skill is actually loaded.
required_environment_variables:
- name: TENOR_API_KEY
prompt: Tenor API key
help: Get a key from https://developers.google.com/tenor
required_for: full functionality
The user may skip setup and keep loading the skill. Hermes only exposes metadata (stored_as, skipped, validated) to the model — never the secret value.
Legacy prerequisites.env_vars remains supported and is normalized into the new representation.
prerequisites:
env_vars: [TENOR_API_KEY] # Legacy alias for required_environment_variables
commands: [curl, jq] # Advisory CLI checks
Gateway and messaging sessions never collect secrets in-band; they instruct the user to run hermes setup or update ~/.hermes/.env locally.
When to declare required environment variables: - The skill uses an API key or token that should be collected securely at load time - The skill can still be useful if the user skips setup, but may degrade gracefully
When to declare command prerequisites:
- The skill relies on a CLI tool that may not be installed (e.g., himalaya, openhue, ddgs)
- Treat command checks as guidance, not discovery-time hiding
See skills/gifs/gif-search/ and skills/email/himalaya/ for examples.
Every new or modernized skill — bundled, optional, or contributed — must meet these standards before merge. Reviewers reject PRs that violate them.
description ≤ 60 characters, one sentence, ends with a period. Long descriptions bloat the skill listing UI and dilute the model's attention when many skills are loaded. State the capability, not the implementation. No marketing words ("powerful", "comprehensive", "seamless", "advanced"). Don't repeat the skill name. Verify with:
python
import re, pathlib
m = re.search(r'^description: (.*)$',
pathlib.Path('skills/<cat>/<name>/SKILL.md').read_text(),
re.MULTILINE)
assert len(m.group(1)) <= 60, len(m.group(1))Good: Search arXiv papers by keyword, author, category, or ID.
Bad: A powerful and comprehensive skill that allows the agent to search arXiv for relevant academic papers using various criteria including keywords, authors, and categories.
`terminal`, `web_extract`, `web_search`, `read_file`, `write_file`, `patch`, `search_files`, `vision_analyze`, `browser_navigate`, `delegate_task`, `image_generate`, `text_to_speech`, `cronjob`, `memory`, `skill_view`, `todo`, `execute_code`.Do NOT name shell utilities the agent already has wrapped:
| Don't say | Say |
|---|---|
grep, rg |
search_files |
cat, head, tail |
read_file |
sed, awk |
patch |
find, ls |
search_files (with target='files') |
curl for content extraction |
web_extract |
echo > file, cat <<EOF |
write_file |
If the skill depends on an MCP server, name the MCP server and document its setup in ## Prerequisites. Third-party CLIs (e.g. ffmpeg, gh, a specific SDK) are fine to invoke from inside script files, but the prose should frame the interaction as "invoke through the terminal tool", not as a manual shell session.
platforms: gating audited against actual script imports. Skills that use POSIX-only primitives (fcntl, termios, os.setsid, os.kill(pid, 0) for liveness, /proc, hardcoded /tmp paths, signal.SIGKILL, bash heredocs, osascript, apt, systemctl) must declare their supported platforms via the platforms: frontmatter. Default posture is to fix it cross-platform first — tempfile.gettempdir(), pathlib.Path, psutil.pid_exists(), Python-level filtering instead of grep. Gate to a narrower set only when the dependency is genuinely platform-bound (e.g. osascript is macOS-only, /proc is Linux-only).
author credits the human contributor first. For external contributions, the contributor's real name + GitHub handle goes first (Jane Doe (jane-doe)); "Hermes Agent" is the secondary collaborator. If the contributor's commit shows "Hermes Agent" as author because they used Hermes to draft the skill, replace it with their actual name — credit the human, not the tool.
SKILL.md body uses the modern section order. # <Skill> Skill title, 2-3 sentence intro stating what it does and what it doesn't do, then:
## When to Use — trigger conditions## Prerequisites — env vars, install steps, MCP setup, API key sourcing## How to Run — canonical invocation through the terminal tool## Quick Reference — flat command/API reference## Procedure — numbered steps with copy-paste commands## Pitfalls — known limits, rate limits, things that look broken but aren't## Verification — single command that proves the skill worksTarget ~200 lines for a complex skill, ~100 lines for a simple one. Cut redundant intro fluff, marketing prose, and re-explanations of env vars already documented in ## Prerequisites.
Scripts go in scripts/, references in references/, templates in templates/. Don't expect the model to inline-write parsers, XML walkers, or non-trivial logic every call — ship a helper script. Reference scripts from SKILL.md by path relative to the skill directory.
Tests live at tests/skills/test_<skill>_skill.py and use only stdlib + pytest + unittest.mock. No live network calls. Run via scripts/run_tests.sh tests/skills/test_<skill>_skill.py -q. Must pass under the hermetic CI env (no API keys leaking through). Use monkeypatch and tmp_path for any env-var or filesystem dependencies.
.env.example additions are isolated to a clearly delimited block. Don't touch the surrounding file — contributor-supplied .env.example versions are usually stale, and edits outside the skill's own block will be dropped during salvage. Comment all values with # (it's documentation, not live config).
web_extract, terminal, read_file).hermes --toolsets skills -q "Use the X skill to do Y" and verify the agent follows the instructions correctly.Hermes uses a data-driven skin system — no code changes needed to add a new skin.
Option A: User skin (YAML file)
Create ~/.hermes/skins/<name>.yaml:
name: mytheme
description: Short description of the theme
colors:
banner_border: "#HEX" # Panel border color
banner_title: "#HEX" # Panel title color
banner_accent: "#HEX" # Section header color
banner_dim: "#HEX" # Muted/dim text color
banner_text: "#HEX" # Body text color
response_border: "#HEX" # Response box border
spinner:
waiting_faces: ["(⚔)", "(⛨)"]
thinking_faces: ["(⚔)", "(⌁)"]
thinking_verbs: ["forging", "plotting"]
wings: # Optional left/right decorations
- ["⟪⚔", "⚔⟫"]
branding:
agent_name: "My Agent"
welcome: "Welcome message"
response_label: " ⚔ Agent "
prompt_symbol: "⚔"
tool_prefix: "╎" # Tool output line prefix
All fields are optional — missing values inherit from the default skin.
Option B: Built-in skin
Add to _BUILTIN_SKINS dict in hermes_cli/skin_engine.py. Use the same schema as above but as a Python dict. Built-in skins ship with the package and are always available.
Activating:
- CLI: /skin mytheme or set display.skin: mytheme in config.yaml
- Config: display: { skin: mytheme }
See hermes_cli/skin_engine.py for the full schema and existing skins as examples.
Hermes runs on Linux, macOS, and native Windows (plus WSL2). When writing code that touches the OS, assume any platform can hit your code path.
Before you PR: run
scripts/check-windows-footguns.pyto catch the common Windows-unsafe patterns in your diff. It's grep-based and cheap; CI runs it on every PR too.
os.kill(pid, 0) for liveness checks. os.kill(pid, 0)
is a standard POSIX idiom to check "is this PID alive" — the signal 0
is a no-op permission check. On Windows it is NOT a no-op. Python's
Windows os.kill maps sig=0 to CTRL_C_EVENT (they collide at the
integer value 0) and routes it through GenerateConsoleCtrlEvent(0, pid),
which broadcasts Ctrl+C to the entire console process group containing
the target PID. "Probe if alive" silently becomes "kill the target and
often unrelated processes sharing its console." See bpo-14484
(open since 2012 — will never be fixed for compat reasons).Preferred: use psutil (a core dependency — always available):
python
import psutil
if psutil.pid_exists(pid):
# process is alive — safe on every platform
...
If you specifically need the hermes wrapper (it has a stdlib fallback
for scaffold-phase imports before pip install finishes), use
gateway.status._pid_exists(pid). It calls psutil.pid_exists first
and falls back to a hand-rolled OpenProcess + WaitForSingleObject
dance on Windows only when psutil is somehow missing.
Audit grep for new callsites: rg "os\.kill\([^,]+,\s*0\s*\)". Any hit
in non-test code is presumptively a Windows silent-kill bug.
shutil.which() before shelling out — don't assume Windows has
tools Linux has. wmic was removed in Windows 10 21H1 and later. ps,
kill, grep, awk, fuser, lsof, pgrep, and most POSIX CLI tools
simply don't exist on Windows. Test availability with
shutil.which("tool") and fall back to a Windows-native equivalent —
usually PowerShell via subprocess.run(["powershell", "-NoProfile",
"-Command", ...]).For process enumeration: PowerShell's Get-CimInstance Win32_Process is
the modern replacement for wmic process. See
hermes_cli/gateway.py::_scan_gateway_pids for the pattern.
termios and fcntl are Unix-only. Always catch both ImportError
and NotImplementedError:
python
try:
from simple_term_menu import TerminalMenu
menu = TerminalMenu(options)
idx = menu.show()
except (ImportError, NotImplementedError):
# Fallback: numbered menu for Windows
for i, opt in enumerate(options):
print(f" {i+1}. {opt}")
idx = int(input("Choice: ")) - 1
File encoding. Windows may save .env files in cp1252. Always
handle encoding errors:
python
try:
load_dotenv(env_path)
except UnicodeDecodeError:
load_dotenv(env_path, encoding="latin-1")
Config files (config.yaml) may be saved with a UTF-8 BOM by Notepad and
similar editors — use encoding="utf-8-sig" when reading files that
could have been touched by a Windows GUI editor.
Process management. os.setsid(), os.killpg(), os.fork(),
os.getuid(), and POSIX signal handling differ on Windows. Guard with
platform.system(), sys.platform, or hasattr(os, "setsid"):
python
if platform.system() != "Windows":
kwargs["preexec_fn"] = os.setsid
else:
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
Preferred: for killing a process AND its children (what os.killpg
does on POSIX), use psutil — it works on every platform:
python
import psutil
try:
parent = psutil.Process(pid)
# Kill children first (leaf-up), then the parent.
for child in parent.children(recursive=True):
child.kill()
parent.kill()
except psutil.NoSuchProcess:
pass
Signals that don't exist on Windows: SIGALRM, SIGCHLD, SIGHUP,
SIGUSR1, SIGUSR2, SIGPIPE, SIGQUIT, SIGKILL. Python's
signal module raises AttributeError at import time if you reference
them on Windows. Use getattr(signal, "SIGKILL", signal.SIGTERM) or
gate the whole block behind a platform check. loop.add_signal_handler
raises NotImplementedError on Windows — always catch it.
Path separators. Use pathlib.Path instead of string concatenation
with /. Forward slashes work almost everywhere on Windows, but
subprocess.run(["cmd.exe", "/c", ...]) and other shell contexts can
require backslashes — convert with str(path) at the subprocess boundary,
not inside Python logic.
Symlinks need elevated privileges on Windows (unless Developer Mode is
on). Tests that create symlinks need @pytest.mark.skipif(sys.platform ==
"win32", reason="Symlinks require elevated privileges on Windows").
POSIX file modes (0o600, 0o644, etc.) are NOT enforced on NTFS by
default. Tests that assert on stat().st_mode & 0o777 must skip on
Windows — the concept doesn't translate. Use ACLs (icacls, pywin32)
for Windows secret-file protection if needed.
Detached background daemons on Windows need pythonw.exe, NOT
python.exe. python.exe always allocates or attaches to a console,
which makes it vulnerable to CTRL_C_EVENT broadcasts from any sibling
process. pythonw.exe is the no-console variant. Combine with
CREATE_NO_WINDOW | DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP |
CREATE_BREAKAWAY_FROM_JOB in subprocess.Popen(creationflags=...).
See hermes_cli/gateway_windows.py::_spawn_detached for the reference
implementation.
subprocess.Popen with .cmd or .bat shims needs shutil.which
to resolve. Passing "agent-browser" to Popen on Windows finds
the extensionless POSIX shebang shim in node_modules/.bin/, which
CreateProcessW can't execute — you'll get WinError 193 "not a valid
Win32 application". Use shutil.which("agent-browser", path=local_bin)
which honors PATHEXT and picks the .CMD variant on Windows.
Don't use shell shebangs as a way to run Python. #!/usr/bin/env
python only works when the file is executed through a Unix shell.
subprocess.run(["./myscript.py"]) on Windows fails even if the file
has a shebang line. Always invoke Python explicitly:
[sys.executable, "myscript.py"].
Shell commands in installers. If you change scripts/install.sh,
make the equivalent change in scripts/install.ps1. The two scripts
are the canonical example of "works on Linux does not mean works on
Windows" and have drifted multiple times — keep them in lockstep.
Known paths that are OneDrive-redirected on Windows: Desktop,
Documents, Pictures, Videos. The "real" path when OneDrive Backup is
enabled is %USERPROFILE%\OneDrive\Desktop (etc.), NOT
%USERPROFILE%\Desktop (which exists as an empty husk). Resolve the
real location via ctypes + SHGetKnownFolderPath or by reading the
Shell Folders registry key — never assume ~/Desktop.
CRLF vs LF in generated scripts. Windows cmd.exe and schtasks
parse line-by-line; mixed or LF-only line endings can break multi-line
.cmd / .bat files. Use open(path, "w", encoding="utf-8",
newline="\r\n") — or open(path, "wb") + explicit bytes — when
generating scripts Windows will execute.
Two different quoting schemes in one command line. subprocess.run
(["schtasks", "/TR", some_cmd]) → schtasks itself parses /TR, AND
the some_cmd string is re-parsed by cmd.exe when the task fires.
Different parsers, different escape rules. Use two separate quoting
helpers and never cross them. See hermes_cli/gateway_windows.py::
_quote_cmd_script_arg and _quote_schtasks_arg for the reference
pair.
Tests that use POSIX-only syscalls need a skip marker. Common ones:
- Symlinks → @pytest.mark.skipif(sys.platform == "win32", ...)
- 0o600 file modes → @pytest.mark.skipif(sys.platform.startswith("win"), ...)
- signal.SIGALRM → Unix-only (per-test timeouts no longer use it directly; see the win32 timeout-method shim in tests/conftest.py::pytest_configure)
- os.setsid / os.fork → Unix-only
- Live Winsock / Windows-specific regression tests →
@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific regression")
If you monkeypatch sys.platform for cross-platform tests, also patch
platform.system() / platform.release() / platform.mac_ver() — each
re-reads the real OS independently, so half-patched tests still route
through the wrong branch on a Windows runner.
Hermes has terminal access. Security matters.
| Layer | Implementation |
|---|---|
| Sudo password piping | Uses shlex.quote() to prevent shell injection |
| Dangerous command detection | Regex patterns in tools/approval.py with user approval flow |
| Cron prompt injection | Scanner in tools/cronjob_tools.py blocks instruction-override patterns |
| Write deny list | Protected paths (~/.ssh/authorized_keys, /etc/shadow) resolved via os.path.realpath() to prevent symlink bypass |
| Skills guard | Security scanner for hub-installed skills (tools/skills_guard.py) |
| Code execution sandbox | execute_code child process runs with API keys stripped from environment |
| Container hardening | Docker: all capabilities dropped, no privilege escalation, PID limits, size-limited tmpfs |
shlex.quote() when interpolating user input into shell commandsos.path.realpath() before path-based access control checksIf your PR affects security, note it explicitly in the description.
After the litellm supply chain compromise in March 2026 and the Mini Shai-Hulud worm campaign in May 2026, all dependencies must follow these rules:
| Source type | Required treatment | Rationale |
|---|---|---|
| PyPI package | >=floor,<next_major |
PyPI versions are immutable once published, but new versions can be pushed into your range. A <next_major ceiling stops a 1.x install from upgrading to a malicious 2.0.0. |
| Git URL (atroposlib, tinker, yc-bench, Baileys) | Full commit SHA | Branches and tags are mutable refs; SHA is content-addressed. |
| GitHub Actions | Full commit SHA + version comment | Action tags are mutable refs (e.g. tj-actions/changed-files March 2025). Pin as uses: owner/action@<sha> # vX.Y.Z |
| CI-only pip installs | ==exact |
Hermetic CI builds; churn is acceptable. |
Every new PyPI dependency in a PR must have a <next_major upper bound. PRs adding unbounded >=X.Y.Z specs will be rejected by reviewers. The supply-chain-audit.yml CI workflow also flags dependency manifest changes for manual review.
How to determine the ceiling:
- If the package is at version 1.x.y, use <2.
- If the package is at version 0.x.y (pre-1.0), use <0.(current_minor + 2) — e.g. if current is 0.29.x, use <0.32. This gives ~2 minor versions of headroom while keeping the window small enough that a hostile takeover version is unlikely to land inside it.
- Exception: packages with very stable APIs (e.g. aiohttp-socks) can use <1 at reviewer discretion.
Examples:
# ✅ Correct — post-1.0
"openai>=2.21.0,<3"
"pydantic>=2.12.5,<3"
# ✅ Correct — pre-1.0 (tight minor window)
"asyncpg>=0.29,<0.32"
"aiosqlite>=0.20,<0.23"
"hindsight-client>=0.4.22,<0.5"
# ❌ Rejected — no upper bound
"some-package>=1.2.3"
# ❌ Rejected — too tight (blocks legitimate patches)
"some-package==1.2.3"
# ❌ Rejected — too loose for pre-1.0 (allows 80 minor versions)
"some-package>=0.20,<1"
Reference PRs: #2796 (litellm removal), #2810 (upper bounds pass), #9801 (SHA pinning + supply-chain-audit CI).
fix/description # Bug fixes
feat/description # New features
docs/description # Documentation
test/description # Tests
refactor/description # Code restructuring
scripts/run_tests.sh (recommended; same as CI) or pytest tests/ -v with the project venv activatedhermes and exercise the code path you changedInclude: - What changed and why - How to test it (reproduction steps for bugs, usage examples for features) - What platforms you tested on - Reference any related issues
We use Conventional Commits:
<type>(<scope>): <description>
| Type | Use for |
|---|---|
fix |
Bug fixes |
feat |
New features |
docs |
Documentation |
test |
Tests |
refactor |
Code restructuring (no behavior change) |
chore |
Build, CI, dependency updates |
Scopes: cli, gateway, tools, skills, agent, install, whatsapp, security, etc.
Examples:
fix(cli): prevent crash in save_config_value when model is a string
feat(gateway): add WhatsApp multi-user session isolation
fix(security): prevent shell injection in sudo password piping
test(tools): add unit tests for file_operations
hermes version), full error tracebackBy contributing, you agree that your contributions will be licensed under the MIT License.