Files
oikos/plans/2026-07-08-nomos-resident-agent.md
dtoro 2b3aa248b1 N0: rename Hermes → Nomos (standalone commit)
Problem: "Hermes" collides with Nous Researchs unrelated product;
  unclear identity for the resident agent.

  Change: Rename the live service identity across 39 files:
  - cmd/hermes/ → cmd/nomos/ (binary, env vars NOMOS_*)
  - internal/config/ server.go (NomosAgentSlug, nomosAgentID)
  - compose/hermes/ → compose/nomos/ (Dockerfile, service name)
  - hermes/ → nomos/ (SOUL.md, config.yaml, skills/)
  - .agents/HERMES.md → NOMOS.md (persona)
  - tools/setup-hermes-soul.sh → setup-nomos-soul.sh
  - seeds/inventory.yaml (agent:hermes → agent:nomos)
  - migrations/014_rename_agent_hermes_to_nomos.up.sql
  - Caddy vhost hermes.hubris.network → nomos.hubris.network
  - All referencing docs, scripts, ADR notes

  History preserved: archive/, plans/done/, ADRs not rewritten.
  Matrix @hermes notifier account and Legacy bin/hermes on LXC 129
  intentionally untouched (out of scope).

  Risk: N0 is identity-only rename; zero behavioral changes.
  Verification: go build ./... passes; docker compose --profile full
  resolves nomos service; grep -ri hermes (excluding archive/plans)
  returns only intentional refs (LLM model name, Matrix user).
2026-07-08 14:14:56 +02:00

14 KiB

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

Status: In Progress — N0 complete 2026-07-08

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.