@mate_mattt: https://x.com/mate_mattt/status/2074313623523271010
Summary
This article provides a detailed breakdown of the Hermes Agent architecture, including the event bus, adapter layer, GatewayRunner core scheduler layer, etc., revealing the design pattern of the Agent as a stateful, event-driven runtime.
View Cached Full Text
Cached at: 07/07/26, 05:34 PM
Detailed Breakdown of Hermes Agent Architecture - Unveiling the Runtime Underneath an Industry-Grade Agent Framework
AI Agents are Stateful, event-driven, and often need to run persistently in the background, constantly looping on their own.
This is exactly the pattern that frontend and client-side developers have been writing for ages (managing complex UI state machines, handling high-frequency user interaction events, RunLoop daemon processes).
If you look at Agent architecture through the lens of traditional programming knowledge, you’ll find it hasn’t invented any new technical frameworks — it simply injects LLM capabilities at certain event nodes.
That’s where the classic formula comes from:
Agent = Harness + Model
By the end of this article, you’ll see that the Harness is a composite of widely used technical architectures, all employing familiar framework concepts.
This is the third article in the Agent architecture series. The previous one analyzed Agent architecture evolution, focusing on LangGraph and Pi-Agent (reading that first will reinforce this one):
MateMatt@mate_mattt·Jul 5
Article
Agent Underlying State Machine Orchestration Evolution – Build a Big-Company Standard Agent Architecture
The previous article introduced the graph representation and 2D matrix representation of state machines, which got good reception:
It seems you folks are quite interested in such articles. Striking while the iron is hot, today we dive deeper into the agent’s underlying state orchestration system, comparing AutoGPT, LangGraph, Pi-Agent…
41 143 18K
If interested, remember to follow me — I will keep publishing in-depth analysis articles in this series.
Hermes Agent Overview
Hermes is not as simple as “throwing an LLM into a ReAct loop”. It is a full-fledged runtime — receiving external events, routing through lifecycle scaffolding, safely scheduling agent work, reusing local agent controllers when possible, and then calling a stateless remote LLM with reconstructed context.
Overall Architecture Panorama:
(Architecture diagram: from left to right)
- User/Platform Entry – Feishu, Telegram, etc.
- Adapter Layer – Normalizes input from different platforms into internal MessageEvent structure.
- Event Bus Layer – Manages full lifecycle listeners and event dispatch.
- GatewayRunner Core Scheduling Layer – Concurrency control, thread management and scheduling (this is the core layer).
- AI Agent – The familiar ReAct implementation/execution layer.
- Stateless LLM Interface Layer – Calls various LLM provider APIs.
We’ll now break down each layer according to this architecture diagram.
Layer 1: User / Platform Entry
The outermost layer is the real-world entry point: a user sends a message on some platform — Telegram, Discord, Slack, CLI, or other integration.
At this point, the message is still a platform-specific “semi-finished product”:
- Telegram update
- Discord message
- CLI input
- Webhook payload
These raw platform payloads are not yet agent tasks. They must first be “sanitized” (standardized) before the core runtime can process them.
Layer 2: Adapter Layer
The adapter layer converts platform-specific input into Hermes internal events.
It answers questions like:
- Who sent it?
- Which Session does it belong to?
- What is the message text?
- Are there images, files, audio attachments?
- What is the
session_key? - Is this Session already busy?
Conceptually, the adapter produces something like this:
MessageEvent + session_key + source metadata
The session_key is extremely important. It is the identity of a local session, used to route work to the correct session channel.
session_key = Session ID card
The adapter also participates in session-level “access control”:
active_sessions[session_key] = busy state guard
pending_messages[session_key] = next turn / interrupt
This means Hermes tries to avoid running two independent agent loops on the same session simultaneously — that would be chaos.
Layer 3: Event Bus / Scaffolding Layer
The Event Bus / Scaffolding layer is the most core conceptual layer of the entire architecture.
This layer is often misunderstood. The Event Bus does not run the ReAct loop, it does not “think”, and it does not call tools as an agent. Its job is to coordinate the lifecycle flow of events throughout the runtime.
You can think of it as a stitched-together combination of:
- An event emitter.
- A middleware pipeline.
- A lifecycle hooks system.
- An asynchronous coordination layer.
- A place to trigger side effects before/after the main agent run.
What the Event Bus does
The Event Bus / Scaffolding layer is responsible for routing and lifecycle control. It deals with concepts like:
publish(event)– publish an eventsubscribe(handler)– subscribe a handlerlifecycle hooks– lifecycle hooksasync dispatch– asynchronous dispatchside effects– side effectsinterrupt signals– interrupt signalspending-message triggers– pending message triggers
A simplified lifecycle looks something like:
on_message_received ——> on_processing_start ——> on_agent_run ——> on_tool_event ——> on_response_sent ——> on_processing_complete
In the source code, these “bus” responsibilities may be spread across adapters, hooks, background tasks, and GatewayRunner, rather than being implemented as a traditional single class called EventBus.
What the Event Bus does not do
The Event Bus does not own these things:
- Worker thread pool.
- AIAgent cache.
- ReAct loop.
- Remote LLM sessions.
- Semantics of local tool execution.
Its role is more like:
“An event occurred. Who needs to be notified? Which lifecycle hooks should run? Should we trigger an interrupt? Should we start processing? Should we send a typing indicator, log, emit metrics, or clean up?”
Analogy: Node.js Event Loop
If you’re familiar with Node.js, this event bus should feel familiar:
Node.js EventEmitter ~= Publish/Subscribe
Express middleware ~= Lifecycle interception
Node Event Loop ~= Async dispatch face
libuv worker thread pool ~= Shared executor (for blocking tasks)
Application handlers ~= GatewayRunner + AIAgent
The Event Bus is not the same as the operating system’s event loop. It is an application-level event coordination pattern built on top of async runtime behavior.
Why does the Harness need this layer?
Without this layer, each platform adapter (tg, feishu, etc.) would have to implement the same logic repeatedly:
- Toggle typing indicator.
- Log message lifecycle events.
- Trigger progress updates.
- Handle interrupts.
- Handle pending messages.
- Run cleanup logic.
- Notify observability or metric handlers.
- Separate platform-specific code from agent logic.
The Event Bus/Scaffolding layer keeps these lifecycle concerns out of the core ReAct loop.
This separation is important:
Event Bus / Scaffolding = Event lifecycle + side effects
AIAgent ReAct loop = Reasoning + Tool calls + Observations + Final answer
Order and Concurrency in the Event Bus
For a single message, lifecycle stages are generally ordered:
Received -> Processing started -> Agent run -> Reply -> Completed
But across different sessions, events can proceed concurrently:
Session A event is being processed
Session B event can start processing at the same time
Session C event is queued waiting for a worker thread
For the same session, Hermes tries to ensure only one active run at a time:
Same session_key -> One active run
New messages during active run -> pending / interrupt / queuing behavior
This is a key design decision. The system allows concurrency across sessions, but protects individual sessions from running multiple conflicting agent loops simultaneously.
Event Bus pseudocode:
async def handle_raw_platform_message(raw):
event = adapter.to_message_event(raw)
session_key = event.session_key
await publish("message_received", event)
if adapter.is_session_active(session_key):
adapter.store_pending_or_interrupt(session_key, event)
await publish("message_pending", event)
return
await publish("processing_start", event)
try:
response = await gateway_runner.handle_message(event)
await publish("response_sent", response)
finally:
await publish("processing_complete", event)
Layer 4: GatewayRunner
GatewayRunner is the global scheduler (Worker) of the runtime.
If the Event Bus is the lifecycle coordination layer, GatewayRunner is the layer that decides how agent work is prepared and executed.
What GatewayRunner manages
GatewayRunner owns or coordinates these main runtime resources:
ThreadPoolExecutor – thread pool
running_agents – active run tracking
agent_cache – AIAgent objects cached by session_key
session store – session storage
queued events – queued events
model/provider config – model/provider configuration
progress and interrupt monitoring – progress and interrupt monitoring
adapters – adapters
When a task arrives, GatewayRunner does not create a permanent worker thread for a session. Instead, it submits the blocking agent work to a shared executor.
Session A borrows worker thread 1
Session B borrows worker thread 2
Session C waits if pool is full
When the agent finishes, the worker thread returns to the pool.
Worker threads are not owned by sessions.
Shared Thread Pool
Hermes uses a shared thread pool to handle blocking agent work.
The practical reason: agent.run_conversation() may call remote APIs, run tools, interact with files, wait for subprocesses, stream output — these tasks should not block the async message processing loop.
So GatewayRunner conceptually does:
await loop.run_in_executor(
shared_thread_pool,
run_sync,
)
Inside run_sync, Hermes eventually calls:
agent.run_conversation(message, conversation_history=history)
Key idea:
Async event handling stays responsive
Blocking agent runs are offloaded to worker threads
From current research, GatewayRunner uses coroutines: _handle_message, _run_agent_inner are async def, and it uses asyncio.create_task() for progress, stream, interrupt monitor tasks. But the core AIAgent loop is not a coroutine — it’s a synchronous function run in the thread pool.
Reference source: gateway/run.py
So the call flow is roughly:
A worker thread is occupied by Session A’s agent run -> agent is waiting for LLM / reading files / running tools -> this worker thread will NOT yield to Session B -> only when the run returns does the thread return to the pool
You can see that when the agent reads a file, it’s obviously an IO blocking operation. But the thread still waits and does not yield; this doesn’t align with the efficiency of modern coroutines. I suspect this is to reduce code complexity, since this is an Agent running on a user machine with very low concurrency requirements. (This is a complex area; please correct me if I’m wrong.)
What happens when the thread pool is full?
If all worker threads are busy, new executor tasks will wait in the executor’s queue.
That’s normal backpressure:
Pool has idle thread -> start immediately
Pool full -> wait
This does NOT mean a session owns a worker thread. It only means the task is waiting for a shared resource to become available.
One Active Run Per Session
GatewayRunner works together with the adapter layer to protect sessions from duplicate runs.
For a single session_key:
Only one active agent run at a time
If a new message arrives while a session is active, Hermes may:
- Store it as a pending next turn.
- Treat it as an interrupt signal.
- Route it as a command.
- If the user uses queue semantics, place it in an explicit FIFO queue.
Important distinction:
Default pending message = usually one next-turn/interrupt slot
Explicit queue = FIFO semantics
So don’t assume every ordinary message goes into a big FIFO queue. The default behavior is more like “protect this session, note what comes next, possibly interrupt it.”
GatewayRunner pseudocode:
async def gateway_handle_message(event):
session_key = event.session_key
mark_running_or_pending(session_key)
def run_sync():
history = load_history(session_key)
config = resolve_model_and_tools(event)
agent = agent_cache.get(session_key, config)
if agent is None:
agent = AIAgent(config=config, session_key=session_key)
agent_cache[session_key] = agent
return agent.run_conversation(
event.text,
conversation_history=history,
)
result = await run_in_executor(shared_thread_pool, run_sync)
persist_result(session_key, result)
clear_running(session_key)
drain_pending_if_any(session_key)
return result.final_response
This shows the core scheduling relationship:
GatewayRunner is responsible for scheduling.
ThreadPoolExecutor provides temporary execution threads.
AIAgent executes the ReAct loop.
Layer 5: AIAgent
AIAgent is the local agent controller. It is a local runtime object that knows:
- Which model/provider to use.
- Which tools are available.
- How to construct messages.
- How to manage session history.
- How to run the ReAct loop.
- How to handle interrupts.
- How to execute tool calls.
- How to produce the final reply.
Internal loop:
- Call LLM -> model returns final answer -> stop
- Call LLM -> model returns tool_calls -> execute tools -> append observations -> call LLM again
This is the ReAct pattern:
Reason -> Act -> Observe -> Repeat
In code:
while not done and iteration_count < max_iterations:
assistant_message = call_llm(messages, tools=tool_schemas)
if assistant_message.tool_calls:
tool_results = execute_tools(assistant_message.tool_calls)
messages.append(tool_results)
continue
return assistant_message.final_text
The loop is local. Intelligence comes from the remote model, but the control structure is local.
Layer 6: Remote LLM API
The remote LLM API is stateless. The remote model doesn’t remember your session.
Each time Hermes calls the model, it sends the entire context needed for that call:
- system prompt
- conversation history
- latest user message
- tool schemas
- tool observations
- runtime instructions
So the diagram shows:
messages/history → reconstruct context
The remote LLM appears to “remember” — only because Hermes re-assembles the session state and sends it again.
Layer 7: Local Tools
Tools are local capabilities exposed to the model, described by structured tool schemas. For example:
- Read file
- Write file
- Run terminal command
- Query memory
- Search context
- Call API
- Generate media
The LLM does not execute these tools directly. It only emits structured tool calls. The local runtime is responsible for validation and execution, then injects the observation back into the message list.
LLM emits tool call -> AIAgent validates it -> local runtime executes it -> result becomes observation -> observation sent back to LLM
Complete Message Flow
End-to-end path:
- User sends a message.
- Platform adapter receives raw platform payload.
- Adapter normalizes it into MessageEvent + session_key.
- Event Bus / Scaffolding publishes lifecycle events.
- Adapter/Runtime checks if the session is already active.
- GatewayRunner prepares session history, configuration, tools, and agent object.
- GatewayRunner submits the blocking agent work to the shared ThreadPoolExecutor.
- A worker thread runs
agent.run_conversation(). - AIAgent enters the ReAct loop.
- Remote LLM is called with messages/history/tool schemas.
- If tool calls are returned, local tools execute and observations are appended.
- Loop repeats until final reply or stop condition.
- GatewayRunner persists and cleans up running state.
- Event Bus / Scaffolding triggers reply and completion hooks.
- Adapter sends the final reply back to the user.
Ownership & Sharing Table
| Component | Owner | Shared? | Lifespan | Purpose |
|---|---|---|---|---|
| GatewayRunner | Process/Runtime | Yes, global within process | Long-lived | Main scheduler |
| ThreadPoolExecutor | GatewayRunner | Yes | Long-lived | Run blocking agent work |
| Worker threads | Thread pool | Yes, borrowed per task | Temporary per submitted run | Execute one submitted run |
| session_key | Runtime/Session system | No | Long-lived | Identity for routing state, pending messages, cached agents |
active_sessions[session_key] | Adapter/Runtime | Per-session | During active run | Prevent duplicate runs on same session |
pending_messages[session_key] | Adapter/Runtime | Per-session | Until consumed | Store next-turn/interrupt |
running_agents[session_key] | GatewayRunner | Per active session | Current run | Track active agent for interrupt/status |
agent_cache[session_key] | GatewayRunner | Per cached session | Across turns until invalidated | Reuse local AIAgent |
| AIAgent | Runtime-created object | Typically bound to a cached session | Across turns | Reuse local ReAct controller |
| Remote LLM API | Provider | Shared external service | Per call | Stateless generation of assistant messages/tool calls |
| Tools | Local runtime | Shared definition, per-call execution | Varies | Perform tool operations |
Summary
A simplified processing flow:
- Event Bus / Scaffolding: Handles lifecycle, routing, hooks, side effects, interrupts
- GatewayRunner: Schedules work, owns shared executor, tracks active agents, manages cache
- ThreadPoolExecutor: Shared worker thread pool; sessions borrow threads temporarily
- session_key: Session ID card; routes state, pending messages, cached agents
- AIAgent: Local ReAct controller; cached by session_key when active
- Remote LLM: Stateless API; context reconstructed via messages/history
This article is fairly long because the Hermes runtime framework itself is quite complex.
If you found it helpful, please like and follow. I will continue to publish in-depth articles on Agent architecture.
Similar Articles
@dotey: For the Hermes Agent architecture documentation, I recommend directly reading the official docs—they're fairly clear. Then use Codex or Claude Code to open the project codebase and let the Agent explain the code to you. If anything is unclear, you can ask follow-up questions. This approach is great because you can ask any questions you want...
It is recommended to directly view the official Hermes Agent architecture documentation and use Codex or Claude Code to open the project codebase, letting the Agent explain the code for deeper understanding.
@libapi_: hermes agent + hermes-web-ui Installation Tutorial: Model Configuration, Panel Installation, Multi-agent Group Chat Collaboration, Scheduled Task Settings, Agent Personality/Memory Settings
The tutorial introduces the installation of hermes agent and hermes-web-ui, model configuration, panel installation, multi-agent group chat collaboration, scheduled task settings, and agent personality/memory settings.
@QingQ77: Provide an out-of-the-box skills and workflow layer for Hermes Agent, covering the entire application lifecycle from idea to deployment to operations. https://github.com/Salomondiei08/oh-my-hermes… Oh My Hermes is to…
Oh My Hermes is an open-source workflow layer that provides out-of-the-box skills and agent teams for Hermes Agent, covering the full application lifecycle from requirements to deployment and operations, turning Hermes into an autonomous operations and development partner.
@qc777qc: https://x.com/qc777qc/status/2055938260103548970
This article introduces how to make Hermes Agent work continuously 24 hours a day using Cron, Gateway, and Heartbeat mechanisms. The key is to use a state file rather than chat context to maintain continuity.
@NFTCPS: Hermes just blew the Agent ceiling away! Programmers everywhere are building desktop marvels, running creative pipelines, and saving tokens like crazy — are you still not on board? First up, hermes-desktop, the CLI toy evolves into a native Mac/Windows desktop app, one-click install and ready to chat, multi-platform messaging + self-evolving loop — that's a true desktop companion. https://github.com/fathah/hermes-desktop
This article introduces five open-source tool projects built around the Hermes Agent, including a desktop application, creative workflow, shared memory layer, token compression tool, and monitoring dashboard, aimed at expanding the Agent ecosystem.