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>
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/callwith 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_activityhypertable +correlation_idconventions — 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/(binarynomos; keepservesubcommand).- Env vars in main.go:
HERMES_MCP_URL,HERMES_LISTEN,HERMES_AGENT_SLUG→NOMOS_*. Default slugagent:nomos;clientInfo.name→nomos. internal/config/config.go:HermesAgentSlugfield +OIKOS_HERMES_AGENT_SLUG→NomosAgentSlug/OIKOS_NOMOS_AGENT_SLUG;hermesAgentIDininternal/httpapi/server.go:140-149renamed to match.hermes/directory →nomos/(SOUL.md,config.yaml,skills/homelab-ops/);tools/setup-hermes-soul.sh→tools/setup-nomos-soul.shwith paths updated.
Deploy
compose/hermes/Dockerfile→compose/nomos/Dockerfile; docker-compose servicehermes:→nomos:(docker-compose.yml:111, env at :63 and :121).- Caddy:
hermes.hubris.networkvhost →nomos.hubris.network(compose/caddy/Caddyfile.oikos:26); keep the old hostname as aredirblock 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:hermesentity) 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.md→nomos-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/hermeswrapper andhermesdon LXC 129 (referenced in.sops.yaml:89,110) — separate legacy systems, untouched. Note: that.sops.yamlentry 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):
- 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;
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
/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. It (or any MCP client) remains usable as an external agent against the MCP endpoint — orthogonal to the resident. - Hand-rolled agent loop (~200 lines): Nomos 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(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
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
NOMOS_MODELper-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-flashdefault,NOMOS_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 / 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 isagent: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./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 — 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
nomosservice (renamed in N0,fullprofile) gainsOPENROUTER_API_KEY,NOMOS_MODEL,DATABASE_URLenv. - Caddy: route
oikos.hubris.network/agent/*→nomos: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. - Nomos enforces trusted-proxy headers (
X-Authentik-Username) whenNOMOS_TRUSTED_PROXY=true, and finally implements themesh_only: truecheck that the config promises 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: Nomos 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
- 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/queryfallback + help. Verify by curl: multi-tool question ("what's degraded and what depends on it?") produces chainedget_health_summary→get_blast_radiuscalls. - N2 — state + streaming: sessions migration, DB persistence, SSE
streaming on
/chat,agent_activitylogging, 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.createdevent 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/chatwith a question requiring 2+ tools; confirm the tool chain in the response and rows inagent_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.