# 2026-07-08 — Hermes resident agent **Status:** Planned ## Goal Turn Hermes from a keyword router into a resident LLM-backed agent: a long-running service you converse with, which reasons over the full 28-tool MCP surface, holds session context, and is surfaced as the **main entry point of the control-room web UI** ([companion plan](2026-07-08-control-room-webui.md)). Oikos already has the agentic substrate — policy classes, approval gating, `agent_activity`, an OODA loop; this gives it the conversational front half. ## What exists vs what's added Today `cmd/hermes/main.go` contains no LLM anywhere: `routeQuery` is `strings.Contains` over ~6 phrases, `extractEntity` knows 5 hardcoded services, and everything else silently falls back to `get_health_summary`. But the hard plumbing already exists and is reused as-is: - `mcpClient` (main.go:190) — working Streamable-HTTP MCP client (initialize → session → `tools/call` with SSE frame parsing). - `listTools()` (main.go:318) — currently dead code; extended (below) it becomes the bridge that feeds MCP tool schemas to Claude. - `.agents/HERMES.md` / `hermes/SOUL.md` — the persona, becomes the system prompt. - `agent_activity` hypertable + `correlation_id` conventions — where tool calls get logged so the control room can show the agent working. ## Architecture decisions ### Hand-rolled tool loop, not a hosted connector or off-the-shelf agent Three ways to get an LLM driving the tools; decision is (3): 1. **Hosted MCP connector** (e.g. Anthropic's `mcp_servers` param): zero loop code, but the provider's servers must reach the MCP endpoint over the public internet. `mcp.hubris.network` is currently exposed *without auth* — a hole the [gaps plan](2026-07-08-oikos-gaps-and-improvements.md) says to close, not to build on. Keeping the MCP surface mesh-private is the right posture for a homelab control plane. 2. **Off-the-shelf agent framework** (evaluated: Nous Research's [Hermes Agent](https://hermes-agent.nousresearch.com/) — MIT, self-hostable, multi-channel personal assistant with memory/subagents/browsing/code-exec). Rejected for this role: it is a desktop/personal-assistant framework with chat-platform frontends, not an embeddable service — there is no clean `/chat` API for the control-room UI to stream from; the oikos-native integration (sessions in Postgres, `agent_activity` + correlation-id joins, approval rail) would not exist; and its browsing/code-exec surface is far larger than a loop confined to the 28 MCP tools. Note it (or any MCP client) remains usable as an *external* agent against the MCP endpoint — that works today and is orthogonal to the resident agent. 3. **Hand-rolled agent loop** (~200 lines): Hermes calls the LLM API with tool definitions translated from `tools/list`, executes returned tool calls through its *existing* `mcpClient` inside the mesh, feeds results back, repeats until the model answers in text. Only conversation text leaves the mesh; the tool transport stays private; the agent is confined to the MCP surface by construction. ### LLM provider: OpenRouter, default model DeepSeek V4 Flash - **OpenRouter** (OpenAI-compatible Chat Completions API) rather than a single-vendor SDK: use the official `openai-go` client with `base_url=https://openrouter.ai/api/v1` and `OPENROUTER_API_KEY`. Model choice becomes one env var; any OpenRouter model can be trialed. - **Default model: `deepseek/deepseek-v4-flash`** — supports tool calling, 1M context, $0.09/M input / $0.18/M output. Use OpenRouter's **Exacto** routing (highest measured tool-calling accuracy) since tool calls are this agent's entire job; low temperature; strict system prompt. - MCP tool `inputSchema` is already JSON Schema — exactly what `tools[].function.parameters` expects — so translation is a field rename. - **Privacy:** conversation text and tool results (hostnames, log excerpts) transit OpenRouter and the upstream provider. Pin OpenRouter provider preferences to ZDR / no-training providers in the request payload. - A flash-class MoE will be weaker than frontier models on long multi-step chains; mitigations are the iteration cap, Exacto routing, and bumping `HERMES_MODEL` per-deployment when a task warrants it. ## Design ### Agent loop (`cmd/hermes/agent.go`, new) ``` system prompt = SOUL.md content (mounted; already provisioned by tools/setup-hermes-soul.sh) tools = listToolsFull() // extend listTools() to return name, description, inputSchema loop (max 15 iterations): resp = chat.completions (OpenRouter, model, system, history, tools) if resp has tool_calls: for each: result = mcpClient.callTool(name, args) // existing code path log to agent_activity (correlation_id = session turn id) append assistant msg + tool-role result msgs to history else: final text → stream to caller, persist turn ``` - Model: `deepseek/deepseek-v4-flash` default, `HERMES_MODEL` override. `max_tokens` and iteration cap configurable; hard per-turn budget so a pathological loop can't burn the API bill. - `OPENROUTER_API_KEY` from env / Infisical (same secret path as other creds). - Mutations need no new guardrails: the agent's only write path is `request_execution`, which flows through the existing risk-class / approval machinery. The agent's actor identity is `agent:hermes`. **Depends on gaps-plan bug A1** (approvals FK) — without that fix the agent's config mutations dead-end silently, which is worse when a conversational agent confidently reports "queued for approval". ### HTTP surface (`cmd/hermes/main.go`) - `POST /chat` `{session_id?, message}` → **SSE stream** of typed events: `text` (deltas), `tool_use` (name + args), `tool_result` (truncated), `done` (session_id, usage). The web UI renders tool calls as inline chips as they happen. - `GET /sessions`, `GET /sessions/{id}` — history for the UI. - `/query` kept for scripts/structured callers. The toy NLU is **removed** (per gaps plan §C): a `query` with no `tool` returns "natural language belongs to /chat" plus the tool list from the now-live `listTools()`. - `/healthz` unchanged. ### Sessions (Postgres, new migration) `agent_sessions (id, title, actor, created_at, last_active_at)` and `agent_messages (id, session_id, role, content JSONB, created_at)` in the shared oikos DB — Hermes gains a `DATABASE_URL` (it's in the same compose stack). DB-backed rather than in-memory so conversations survive restarts and the control room can list/replay them. Tool invocations additionally go to `agent_activity` with the session's correlation_id, so the existing agent-activity page and the ops ledger join up with zero new query paths. ### Connectivity & auth - Compose: hermes already runs under the `full` profile; add `OPENROUTER_API_KEY`, `HERMES_MODEL`, `DATABASE_URL` env. - Caddy: route `oikos.hubris.network/agent/*` → `hermes:8092` **behind the same Authentik forward_auth** as the rest of the vhost, stripping the `/agent` prefix. The web UI then calls same-origin `/agent/chat` — no CORS, and EventSource/fetch-streaming work unmodified. - Hermes enforces trusted-proxy headers (`X-Authentik-Username`) when `HERMES_TRUSTED_PROXY=true`, and finally implements the `mesh_only: true` check that `hermes/config.yaml:9` promises but `main.go` never enforces (gaps plan B3). Direct :8092 access stays mesh-only for agents/scripts. ### Web UI entry point (amends the control-room plan) The agent chat becomes the **home view** of the control room (`/ui/#/`): - Center: conversation pane (streamed text, expandable tool-call chips showing args/results, correlation-id links into the ops ledger). - Right rail: live context — pending approvals with approve/deny buttons, recent events, health strip. When the agent's `request_execution` needs approval, the approval card appears in the rail *mid-conversation* (via the `approval.created` SSE event) and can be decided without leaving chat. That's the whole product in one screen: ask → watch it act → approve → watch it complete. - A persistent chat drawer is available from every other page. - Session list in the nav; sessions resumable. ### Later (explicitly out of scope for v1) - Matrix bridge: Hermes as a Matrix bot in the operator room, reusing the notifier's homeserver credentials — same `/chat` loop, different frontend. - Proactive mode: agent opens a session itself when a signal fires (escalation-with-context instead of a bare alert). ## Milestones - **H1 — loop:** `agent.go` + openai-go (OpenRouter base URL); `listToolsFull()`; `/chat` (non-streaming JSON first); remove toy NLU, fix `/query` fallback + help. Verify by curl: multi-tool question ("what's degraded and what depends on it?") produces chained `get_health_summary` → `get_blast_radius` calls. - **H2 — state + streaming:** sessions migration, DB persistence, SSE streaming on `/chat`, `agent_activity` logging, budgets. - **H3 — UI entry point:** home chat view + context rail + drawer in the control-room SPA (needs control-room M1; rail approvals need M2's `approval.created` event and gaps-plan A1). - **H4 (optional):** Matrix bridge, proactive sessions. ## Files ``` cmd/hermes/agent.go # Claude loop (new) cmd/hermes/main.go # /chat, /sessions, NLU removal, mesh/proxy auth cmd/hermes/store.go # session persistence (new) migrations/0xx_agent_sessions.up.sql hermes/config.yaml # model, budgets; drop query_routing block compose/ (env), Caddyfile.oikos (/agent route) web/src/pages/Chat.svelte + lib/stores/chat.ts # control-room home view go.mod # openai-go (OpenRouter-compatible client) ``` ## Verification - H1: `curl -N /agent/chat` with a question requiring 2+ tools; confirm the tool chain in the response and rows in `agent_activity`. - H2: restart hermes mid-session, resume by session_id, history intact. - H3: from the UI home, ask Hermes to restart a low-risk service; watch the tool chips stream, the execution appear in the rail, and (for a gated action) the approval card arrive and be decidable in place.