Architecture
Big picture
HolmesGPT is a Python program wrapped around one loop. A CLI entrypoint loads config and builds a system prompt plus a user prompt from the alert or question. The engine (ToolCallingLLM) then calls the model, and if the model asks for tools, runs them and feeds the results back, iterating until the model stops or a step limit is hit. The model reaches data sources through toolsets, and it reaches the model provider through a LiteLLM wrapper that hides whether the backend is OpenAI, Anthropic, Azure, Bedrock, or Gemini. Everything the model does is orchestration by Python; everything Python does is safety and plumbing around the model's choices.
Components
CLI and config
holmes/main.py is the Typer CLI with ask, investigate, and toolset subcommands. It loads configuration and routes commands. holmes/config.py reads ~/.holmes/config.yaml for the model, API key, and enabled toolsets, and is the factory for alert sources (AlertManager, Jira, PagerDuty, OpsGenie). This layer decides what the engine will run with; it does not run the loop itself.
Prompt building
holmes/core/prompt.py assembles the system and user prompts from Jinja2 templates in holmes/plugins/prompts/. generic_ask.jinja2 is the system prompt, built from components (intro, cluster_name, todowrite, toolset_instructions, style_guide) that can be turned on or off (prompt.py:12 PromptComponent, prompt.py:97 is_component_enabled, controlled by the ENABLED_PROMPTS environment variable). Runbooks and any guidance enter the model here, as prompt text.
Engine
holmes/core/tool_calling_llm.py holds ToolCallingLLM (tool_calling_llm.py:196), the loop. It owns tool_executor, max_steps, and the LLM handle. call (tool_calling_llm.py:575) is the synchronous entry that drains call_stream (tool_calling_llm.py:1031), the loop body. Despite the name, call_stream does not stream LLM tokens; it yields one Holmes iteration at a time, and each model call runs with stream=False (a comment states this at tool_calling_llm.py:1044).
LLM abstraction
holmes/core/llm.py holds DefaultLLM, which calls providers through LiteLLM, so one code path serves OpenAI, Anthropic, Azure, Bedrock, and Gemini. Token counting and context-window sizing live here too, which is what the compaction step later depends on.
Tools and toolsets
holmes/core/tools.py defines Tool.invoke (tools.py:353) and the unified output type StructuredToolResult (tools.py:96), whose status enum distinguishes success, error, no_data, approval_required, and frontend_pause (tools.py:64). holmes/plugins/toolsets/ holds 46 data-source integrations (Kubernetes, Prometheus, Grafana, Datadog, AWS, Docker, Elasticsearch, MCP, and more), mixing YAML definitions with Python implementations. holmes/plugins/sources/ ingests alerts (GitHub, Jira, OpsGenie, PagerDuty, and Prometheus AlertManager), and holmes/plugins/destinations/ writes findings back out.
How a request flows
Trace holmes investigate, which analyzes one alert end to end:
_investigate_issue(main.py:163) builds the system prompt withbuild_system_prompt(main.py:173intoprompt.py:177) and the user prompt withgenerate_user_prompt(main.py:181intoprompt.py:161). The investigation-specific instruction is added atmain.py:172("Provide a terse analysis of the following ... alert/issue and why it is firing."). It assemblesmessages = system + userand callsai.call(messages, ...)(main.py:189).ToolCallingLLM.call(tool_calling_llm.py:575) drainscall_stream, whose loop iswhile i < max_steps(tool_calling_llm.py:1101).- Each iteration calls the model once:
self.llm.completion(messages=..., tools=tools, tool_choice="auto", temperature=TEMPERATURE, stream=False, drop_params=True)(tool_calling_llm.py:1163), through LiteLLM (llm.py). - If the response has no
tool_calls, the loop emitsANSWER_END(tool_calling_llm.py:1262) and returns; the message content is the final root cause analysis the model wrote. - If it has
tool_calls, they run in aThreadPoolExecutor(max_workers=16)(tool_calling_llm.py:1295), each call reachingTool.invoke(tools.py:353) to hit the real data source. Results are appended tomessagesand the loop iterates. Whenireachesmax_steps, the tools are removed so the model must conclude (tool_calling_llm.py:1101).
Key design decisions
The decisive choice is where judgment lives. Deterministic Python owns loop control and max_steps, tool dispatch, the duplicate-call safeguard (safeguards.py:24), the approval gate (tools.py:363), context compaction and oversized-output spilling, and token accounting and tracing. The model owns which tool to call, with which arguments, when to stop, and the wording of the analysis. There is no hardcoded diagnostic decision tree; runbooks are prompt-injected text, so the execution decision still belongs to the model (recon; source at 84cb39c). The result is an agent whose reasoning is entirely the model's, with Python confined to running tools safely and returning their output in a form the context can hold.
A second choice is per-iteration parallelism. A single step can issue several tool calls at once, run up to 16 in parallel, and return them together, which cuts the wall-clock cost of an investigation that needs many independent lookups (tool_calling_llm.py:1295).
A third is that all built-in tools are read-only by design. That is what makes the duplicate-call safeguard sound: refusing an identical repeated call is only safe because tools do not mutate state, and a source comment notes the safeguard would need to change if that ever stopped being true (safeguards.py:24).
Extension points
- Toolsets: a new data source is a new toolset under
holmes/plugins/toolsets/, defined in YAML or Python, exposing read-only commands the model can call. - MCP: toolsets can reach external tools through the Model Context Protocol, which is how integrations such as GitHub, GitLab, and cloud providers are wired in (README).
- Alert sources:
holmes/plugins/sources/adds ingestion from systems such as AlertManager, PagerDuty, OpsGenie, and Jira, some of which can also receive findings back. - Prompt components: the system prompt is assembled from toggleable components (
prompt.py:12,prompt.py:97), controlled byENABLED_PROMPTS, so guidance and runbooks can be swapped without touching the engine.