Swap anthropic-sdk-go for openai-go against the OpenRouter API; default model deepseek/deepseek-v4-flash with Exacto routing and ZDR provider pinning. Record why Nous Hermes Agent (and hosted MCP connectors) were rejected for the resident role. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
10 KiB
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).
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/callwith 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_activityhypertable +correlation_idconventions — 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):
- Hosted MCP connector (e.g. Anthropic's
mcp_serversparam): zero loop code, but the provider's servers must reach the MCP endpoint over the public internet.mcp.hubris.networkis currently exposed without auth — a hole the gaps plan says to close, not to build on. Keeping the MCP surface mesh-private is the right posture for a homelab control plane. - Off-the-shelf agent framework (evaluated: Nous Research's
Hermes Agent — 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
/chatAPI 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. - Hand-rolled agent loop (~200 lines): Hermes calls the LLM API with
tool definitions translated from
tools/list, executes returned tool calls through its existingmcpClientinside 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-goclient withbase_url=https://openrouter.ai/api/v1andOPENROUTER_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
inputSchemais already JSON Schema — exactly whattools[].function.parametersexpects — 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_MODELper-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-flashdefault,HERMES_MODELoverride.max_tokensand iteration cap configurable; hard per-turn budget so a pathological loop can't burn the API bill. OPENROUTER_API_KEYfrom 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 isagent: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./querykept for scripts/structured callers. The toy NLU is removed (per gaps plan §C): aquerywith notoolreturns "natural language belongs to /chat" plus the tool list from the now-livelistTools()./healthzunchanged.
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
fullprofile; addOPENROUTER_API_KEY,HERMES_MODEL,DATABASE_URLenv. - Caddy: route
oikos.hubris.network/agent/*→hermes:8092behind the same Authentik forward_auth as the rest of the vhost, stripping the/agentprefix. 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) whenHERMES_TRUSTED_PROXY=true, and finally implements themesh_only: truecheck thathermes/config.yaml:9promises butmain.gonever 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_executionneeds approval, the approval card appears in the rail mid-conversation (via theapproval.createdSSE 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
/chatloop, 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/queryfallback + help. Verify by curl: multi-tool question ("what's degraded and what depends on it?") produces chainedget_health_summary→get_blast_radiuscalls. - H2 — state + streaming: sessions migration, DB persistence, SSE
streaming on
/chat,agent_activitylogging, 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.createdevent 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/chatwith a question requiring 2+ tools; confirm the tool chain in the response and rows inagent_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.