Files
oikos/plans/done/2026-07-08-nomos-resident-agent.md
dtoro ef5a92269b docs: reconcile plans/ status against actual code state
Audited all 10 active plan docs against the codebase (not just commit
titles). 5 were fully shipped and stale-tagged "Planned"/"In Progress" —
moved to done/ with verification notes. The other 4 got corrected
Planned→In Progress status plus concrete remaining-gap notes so the next
pass doesn't re-derive what's already done.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 11:42:26 +02:00

14 KiB

2026-07-08 — Nomos resident agent (renames Hermes)

Status: Done — 2026-07-11. N0-N3 (rename, agent loop, sessions/streaming, UI entry point) all verified in current code. N4 (Matrix bridge, proactive sessions) was explicitly out of scope and remains unstarted.

Goal

Rename the gateway formerly known as Hermes to Nomos (from oikonomos, the steward of the oikos — and avoiding confusion with Nous Research's unrelated "Hermes Agent" product), and turn it 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/call with SSE frame parsing).
  • listTools() (main.go:318) — currently dead code; extended (below) it becomes the bridge that feeds MCP tool schemas to the LLM.
  • .agents/HERMES.md / hermes/SOUL.md — the persona, becomes the system prompt (renamed in N0).
  • agent_activity hypertable + correlation_id conventions — where tool calls get logged so the control room can show the agent working.

Milestone N0 — the rename (Hermes → Nomos)

Do this first, as its own commit, before any agent code lands. Scope is the live service identity; history (archive/, plans/done/, past investigations) is never rewritten.

Code & build

  • cmd/hermes/cmd/nomos/ (binary nomos; keep serve subcommand).
  • Env vars in main.go: HERMES_MCP_URL, HERMES_LISTEN, HERMES_AGENT_SLUGNOMOS_*. Default slug agent:nomos; clientInfo.namenomos.
  • internal/config/config.go: HermesAgentSlug field + OIKOS_HERMES_AGENT_SLUGNomosAgentSlug / OIKOS_NOMOS_AGENT_SLUG; hermesAgentID in internal/httpapi/server.go:140-149 renamed to match.
  • hermes/ directory → nomos/ (SOUL.md, config.yaml, skills/homelab-ops/); tools/setup-hermes-soul.shtools/setup-nomos-soul.sh with paths updated.

Deploy

  • compose/hermes/Dockerfilecompose/nomos/Dockerfile; docker-compose service hermes:nomos: (docker-compose.yml:111, env at :63 and :121).
  • Caddy: hermes.hubris.network vhost → nomos.hubris.network (compose/caddy/Caddyfile.oikos:26); keep the old hostname as a redir block for one transition window; add the DNS record. (These live in the external caddy-conf repo / DNS — operator step.)

Data (identity-preserving — do NOT create a new entity)

  • Live DB: UPDATE entities SET slug='agent:nomos', attributes = jsonb_set(attributes,'{name}','"nomos"') WHERE slug='agent:hermes'; — relationships/audit reference the UUID, so history survives the slug change. Ship as a migration so every environment gets it.
  • seeds/inventory.yaml:328 (agent:hermes entity) and :541 (owns relationship) → agent:nomos. Seed upserts by slug, so the migration must run before seeding or the seed would mint a duplicate entity.

Docs & persona

  • .agents/HERMES.md.agents/NOMOS.md; update every referencing doc (AGENTS.md, CLIENTS.md, README.md, CONTRIBUTING.md, .agents/operations/hermes-agent.mdnomos-agent.md, .agents/operations/commands.md, skills docs).
  • ADR-0012 (docs/adr/0012-hermes-oikos-interactions.md): append a "renamed to Nomos" note; don't rewrite the ADR.

Explicitly out of scope

  • Matrix user @hermes:hubris.network (docker-compose.yml:103) — that's the notifier's homeserver account; renaming it is an operational Matrix task, optional and later.
  • Legacy bin/hermes wrapper and hermesd on LXC 129 (referenced in .sops.yaml:89,110) — separate legacy systems, untouched. Note: that .sops.yaml entry shows an OpenRouter API key path already exists in the secrets tree — reuse it for N1.
  • Historical knowledge/investigation pages and executed plans.

Verify N0: go build ./...; docker compose --profile full config resolves; curl nomos.hubris.network/healthz (and the old vhost redirects); SELECT slug FROM entities WHERE slug LIKE 'agent:%' shows agent:nomos with its original UUID; grep -ri hermes returns only history/ADR/legacy hits.

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 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 — MIT, self-hostable, multi-channel personal assistant with memory/subagents/browsing/code-exec; also the naming-collision motivation for N0). 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. It (or any MCP client) remains usable as an external agent against the MCP endpoint — orthogonal to the resident.
  3. Hand-rolled agent loop (~200 lines): Nomos 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 (secret path already exists in .sops.yaml). Model choice becomes one env var.
  • 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 NOMOS_MODEL per-deployment when a task warrants it.

Design

Agent loop (cmd/nomos/agent.go, new)

system prompt  = SOUL.md content (mounted; provisioned by tools/setup-nomos-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, NOMOS_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 / SOPS (existing path).
  • 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:nomos. 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/nomos/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 — Nomos 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: the nomos service (renamed in N0, full profile) gains OPENROUTER_API_KEY, NOMOS_MODEL, DATABASE_URL env.
  • Caddy: route oikos.hubris.network/agent/*nomos: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.
  • Nomos enforces trusted-proxy headers (X-Authentik-Username) when NOMOS_TRUSTED_PROXY=true, and finally implements the mesh_only: true check that the config 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: Nomos 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

  • N0 — rename: see above; standalone commit, deployable on its own.
  • N1 — 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_summaryget_blast_radius calls.
  • N2 — state + streaming: sessions migration, DB persistence, SSE streaming on /chat, agent_activity logging, budgets.
  • N3 — 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).
  • N4 (optional): Matrix bridge, proactive sessions.

Files

cmd/nomos/{main.go, agent.go, store.go}   # renamed + loop + sessions
internal/config/config.go, internal/httpapi/server.go   # slug config rename
nomos/{SOUL.md, config.yaml, skills/}     # renamed dir; drop query_routing
tools/setup-nomos-soul.sh
migrations/0xx_rename_agent_hermes_to_nomos.up.sql
migrations/0xx_agent_sessions.up.sql
seeds/inventory.yaml
compose/nomos/Dockerfile, docker-compose.yml, Caddyfile.oikos (/agent route, vhost)
.agents/NOMOS.md + referencing docs; docs/adr/0012 note
web/src/pages/Chat.svelte + lib/stores/chat.ts   # control-room home view
go.mod                                    # openai-go

Verification

  • N0: see milestone N0 verify list.
  • N1: curl -N /agent/chat with a question requiring 2+ tools; confirm the tool chain in the response and rows in agent_activity.
  • N2: restart nomos mid-session, resume by session_id, history intact.
  • N3: from the UI home, ask Nomos 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.