Agent (cmd/nomos): - Stream LLM tokens via NewStreaming; emit text_delta then final text. - OpenRouter provider routing: data_collection=deny (ZDR) + require_parameters; NOMOS_PROVIDER_SORT opt-in; Exacto via model suffix. - Multi-turn: reload session history into context; UI passes session id. - Fix agent_activity logging (agent_id/session_id) and mcpClient data race. Events (live control-room feed): - approval.created (mcp), approval.decided (api), execution.completed/failed (approved-action path), signal.raised/resolved + health.changed (scheduler, transition-gated). Fixes: - createApproval FK violation (reuse execution entity) — the agent's only write path; log the previously-swallowed errors. Web UI: - Embed web/dist via //go:embed (single binary); Dockerfile builds SPA into the Go stage; committed .gitkeep placeholder keeps backend-only builds green. - Caddy: Authentik-gated /agent/* -> nomos so the UI reaches the agent same-origin in production. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
14 KiB
2026-07-08 — Nomos resident agent (renames Hermes)
Status: In Progress — N0-N3 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/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.