Hermes becomes an LLM-backed agent loop (hand-rolled tool loop over the existing mcpClient, not the public MCP connector), with Postgres-backed sessions, SSE chat streaming, and Authentik-gated /agent routing. The control-room plan is amended to make the chat the main entry point. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
8.1 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 decision: hand-rolled tool loop, not the MCP connector
Two ways to let Claude drive the tools:
- Anthropic MCP connector (
mcp_serversparam on the Messages API): zero loop code, but Anthropic'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. - Hand-rolled agent loop (~200 lines): Hermes calls the Messages API
with tool definitions translated from
tools/list, executes Claude's tool_use blocks through its existingmcpClientinside the mesh, feeds backtool_resultblocks, repeats until Claude answers in text.
Decision: (2). MCP tool inputSchema is already JSON Schema — the exact
format the Anthropic tools parameter expects — so translation is a field
rename. Only the conversation text ever leaves the mesh; the tool transport
stays private. Use the official anthropic-sdk-go.
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 = messages.New(model, system, history, tools)
if resp has tool_use blocks:
for each: result = mcpClient.callTool(name, args) // existing code path
log to agent_activity (correlation_id = session turn id)
append assistant msg + tool_result user msg to history
else: final text → stream to caller, persist turn
- Model:
claude-sonnet-5default,HERMES_MODELoverride.max_tokensand iteration cap configurable; hard per-turn budget so a pathological loop can't burn the API bill. ANTHROPIC_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; addANTHROPIC_API_KEY,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+ anthropic-sdk-go;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 # anthropic-sdk-go
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.