Architecture Overview
Hermes Agent is an open-source (MIT) desktop AI agent by Nous Research, positioned as "a persistent agent that grows with you": a message arrives from Telegram/Discord/Slack/etc, the core loop calls the model + tools to complete the task, and the experience is distilled into memory and skills for reuse next time.
Layered View
Each layer in one sentence
- UI Layer: TUI / CLI / Web / ACP (editor protocol) — just entry points, hold no business logic.
- Gateway:
gateway/run.pyunifies access; platforms are adapters; session/delivery separated; streams and hooks pluggable. - Core Loop:
run_conversationorchestrates "model call → tool call → result backfill → budget decrement", with fallback on failure. - Capabilities: Tools are atomic functions (
tools/registry.py); Skills are higher-order evolvable flows (rewritten by the learning loop); Provider/MCP/Plugins supply models and external capabilities. - Scheduling: Cron drives timed tasks, Subagent delegates in isolation (zero context cost), 6 sandbox backends isolate execution.
- Learning Loop: Distills conversation experience into skills and a user model — the flywheel behind "better the more you use it".
Top-Down: Representative Signatures per Layer
The fastest way to understand Hermes' layering is to look at the "signature" each layer exposes in the code.
UI Layer → forwarder into the core loop. Every shell calls AIAgent.run_conversation, but it's just a forwarder (run_agent.py:6350):
def run_conversation(self, user_message, system_message=None,
conversation_history=None, ..., moa_config=None) -> Dict[str, Any]:
"""Forwarder — see ``agent.conversation_loop.run_conversation``."""
from agent.conversation_loop import run_conversation
token = set_conversation_context(self._conversation_root_id())
with scoped_runtime_main({}):
return run_conversation(self, user_message, ...)The conversation ID is pushed down via a ContextVar — every LLM call inside the main loop (compression, vision, MoA slot, background-review fork) picks up the same tag automatically.
Gateway Layer → the platform-adapter manager. GatewayRunner mixes in authorization/dashboard/slash-command capabilities via mixins (gateway/run.py:22392); the entry start_gateway(replace=True) kills any old instance first — a backdoor for systemd restarts where the previous process hasn't fully exited:
class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, GatewaySlashCommandsMixin):
"""Main gateway controller. Manages the lifecycle of all platform adapters
and routes messages to/from the agent."""Core Loop → where the real work happens. run_conversation lives in agent/conversation_loop.py, orchestrating "model call → tool call → result backfill → budget decrement" with fallback on failure; signature above.
Capabilities → tool registration. Each tools/*.py calls registry.register at module-import time (tools/registry.py:365); override=True is required for a plugin to replace a built-in tool:
def register(self, name, toolset, schema, handler, check_fn=None,
requires_env=None, is_async=False, description="", emoji="",
max_result_size_chars=None, dynamic_schema_overrides=None, override=False):
"""Register a tool. Called at module-import time by each tool file.
``override=True`` is an explicit opt-in for plugins ... Without it,
registrations that would shadow an existing tool are rejected."""A plugin that wants to replace a built-in tool must explicitly pass override=True and go through the allow_tool_override config — preventing a plugin from silently swapping out the browser tool. Same-name MCP-tool overrides go through a separate channel.
Scheduling → the cron tick. tick (cron/scheduler.py:3888) is a single scan guarded by a file lock; the gateway calls it once every 60 seconds from a background thread. The adapters parameter lets cron tasks reuse the platform connections the gateway has already built, instead of reconnecting on its own.
Learning Loop → learning-graph aggregation. build_learning_graph (agent/learning_graph.py:248-328) pieces learned (non-built-in) skills and memory into nodes/edges. It first calls build_skill_nodes(_skill_roots()) to scan both the base and profile roots, then filters skills with source != "base" and (created_by == "agent" or use_count > 0) as "learned" — built-in skills stay out of the learning graph so noise doesn't drown out the evolution signal.
Layering Motivation
Why this layering? In one line: let the layers that can change not pollute the layers that can't.
- The UI layer swaps shells often (TUI/Web/IDE protocols each iterate at their own pace), and swapping shells must not force business changes; so shells only attach callbacks (
step_callback/event_callback) and don't enterrun_conversationinternals. - The gateway layer adds new platforms all the time (QQbot, Enterprise WeChat, new Discord styles), but the core loop must not be platform-aware — so platform logic stays in adapters, and the gateway only decouples messages from agent calls.
- The capabilities layer is "atoms"; the learning loop is "evolution" — atoms must not be polluted by the evolution path (otherwise a background-review bug breaks a tool and takes the main loop down with it), so
learning_mutationswrites toskills/nottools/. - Scheduling is a cross-cutting concern: Cron and Subagent both "trigger the main loop from outside the main loop", sharing
run_conversationwithout directly holding agent state.
Common Misreadings
- "Hermes is an agent framework" — it isn't. It's a concrete desktop agent implementation; the core loop, tool set, and skills directory all ship with Hermes. The framework-like surfaces (MCP, Plugin, Provider abstractions) are boundaries it exposes for extensibility, not its essence.
- "The learning loop auto-optimizes the model prompt" — it doesn't. It only edits
SKILL.mdunderskills/and memory under~/.hermes/skills/, never the system prompt templates. Prompt templates are fixed files in the repo; changing them takes a release. - "Subagent is distributed" — it isn't. Subagent forks the agent in-process (shared runtime); sandboxes are execution isolation (code runs in Docker/SSH/Modal/Daytona), not distribution of agent state.
- "Gateway layer = messaging-platform access" — only half right. The gateway also owns session state (
session/), delivery deduplication (delivery_ledger), streaming dispatch (stream_*), slash commands, and kanban watchers — these are all "between the messaging platform and the agent", not just access.
Suggested reading order
- Startup & Entry — how a command becomes a running agent
- Main Loop — what happens when a message arrives
- Capabilities — the difference between tools and skills
- Gateway — how multi-platform delivery works
- Scheduling — automation and isolation
- Learning Loop — the self-improvement mechanism