Containerized Deployment — the KG platform as microservices¶
How the agent-utilities Knowledge Graph platform runs fully containerized: a set of independently deployable services sharing one data plane (the Rust
epistemic-graphengine + a durable Postgres queue), fronted by JWT/Eunomia auth and Caddy.arpaingress. This replaced the old bare-metal systemd units (epistemic-graph.service,agent-utilities-gateway.service,agent-utilities-messaging.service) with containers — zero systemd.Ground truth for this page: the committed compose files under
services/graph-os/,services/mcp-multiplexer/, and the representativeservices/github-mcp/editable fleet service.
Overview¶
The platform is not a monolith. It is decomposed into single-responsibility services that all read and write the same graph through a shared low-level data plane, and submit work onto one durable queue:
| Service | Process | Role | Served surface |
|---|---|---|---|
| epistemic-graph engine | Rust epistemic-graph |
the ONE database — authority/system of record (compute + cache + semantic + durable persistence); serves a Unix Domain Socket (UDS), optionally TCP | engine socket / tcp://…:9090 (no HTTP) |
| graph-os-host | agent_utilities.gateway.daemon (KG_DAEMON_ROLE=host) |
the single KG host daemon — maintenance scheduler, task/ingest workers, embed-backfill, write-fanout to mirrors, owns the host-lock | none (no port) |
| graph-os | agent_utilities.mcp.kg_server (KG_DAEMON_ROLE=client) |
the graph-os MCP surface over streamable-http, JWT-authenticated | graph-os.arpa |
| mcp-multiplexer | agent_utilities.mcp.multiplexer |
fronts the whole fleet (dynamic find_tools/load_tools); JWT auth + Eunomia zero-trust policy |
mcp-multiplexer.arpa |
| messaging daemon | messaging.daemon (client) |
chat reply / inbound router; connects to the engine + the multiplexer; loads OIDC fleet creds | inbound chat only (no HTTP port) |
*-mcp fleet |
one editable service per connector (github, gitlab, …) | one external system each, behind the multiplexer | <svc>.arpa |
The coupling point is deliberate: every KG client (graph-os,
graph-os-host, the messaging daemon) talks to the same engine over the
shared socket and submits to the same durable Postgres queue that
graph-os-host drains. The *-mcp connectors are independent — they front
external systems and are reached only through the multiplexer.
flowchart TB
subgraph clients[Clients]
Claude([Claude / IDE agent])
Browser([agent-webui / terminal-ui])
User([User on Telegram/Slack])
end
subgraph edge[Caddy .arpa ingress]
MUX[["mcp-multiplexer.arpa<br/>(JWT azp → Eunomia PDP)"]]
GOS[["graph-os.arpa<br/>(JWT → ActorContext + ActionPolicy)"]]
end
subgraph fleet["*-mcp fleet (one editable service each)"]
GH[github-mcp.arpa]
GL[gitlab-mcp.arpa]
ETC[…58 connectors]
end
subgraph dataplane[Shared data plane]
ENG[("epistemic-graph engine<br/>Rust · UDS /run/epistemic-graph<br/>or tcp:// (HRW sharding)")]
HOST["graph-os-host<br/>(gateway.daemon, role=host)<br/>scheduler · workers · fanout · host-lock"]
Q[("durable Postgres queue<br/>STATE_DB_URI · TASK_QUEUE_BACKEND")]
MIR[(neo4j / falkordb / ladybug mirrors)]
end
MSG["messaging daemon<br/>(messaging.daemon, role=client)"]
Claude -->|JWT bearer| MUX
Browser -->|JWT bearer| MUX
Claude -->|JWT bearer| GOS
User -->|chat in| MSG
MUX -->|find_tools / load_tools| GH & GL & ETC
MUX -->|go__* graph tools| GOS
GOS -->|engine socket| ENG
GOS -->|enqueue| Q
MSG -->|engine socket| ENG
MSG -->|delegate| MUX
HOST -->|drains| Q
HOST -->|engine socket| ENG
HOST -->|write-fanout| MIR
Are these microservices?¶
Yes. Each box above is an independently deployable, independently scalable service with one responsibility, communicating over network contracts (the engine UDS/TCP, the durable Postgres queue, and MCP over streamable-http) rather than in-process calls:
- Independently deployable — each has its own compose stack, image, env, and
Portainer GitOps source; you can redeploy
github-mcpwithout touchinggraph-os. - Independently scalable —
graph-os(the MCP surface) replicates horizontally behind Caddy; the*-mcpconnectors scale per load; the engine shards by tenant;graph-os-hoststays a singleton (it owns the host-lock). - Single responsibility —
graph-osserves MCP,graph-os-hostruns the background plane, the multiplexer does discovery+auth, each*-mcpfronts one external system. - Not a monolith — there is no one process that does everything; the old bare-metal "one big gateway" was split apart.
The trade-off is honest: the shared engine data plane is the coupling point. All KG clients depend on one engine (per shard) and one durable queue schema. That is a deliberate choice — a single source of truth for the graph beats N divergent copies — but it means the engine and the queue are the blast radius, so they get the durability (snapshots, checkpoints, SKIP-LOCKED claims, advisory-lock leadership) and the placement care. Within a shard the services are loosely coupled around that shared substrate; across shards the engine is partitioned by tenant (HRW routing), so the data plane itself scales out.
Dev deployment (fully editable)¶
The dev pattern is edit-on-host, restart-the-service, changes-are-live — no image build. It works the same for every KG service:
- Base image supplies the heavy runtime deps, e.g.
knucklessg1/agent-utilities:latestforgraph-os/graph-os-host,python:3.11-slimfor the multiplexer (which pip-installsrequirements-multiplexer.txtat start). - Bind-mount
/au— the agent-utilities source from the host (/home/apps/workspace/agent-packages/agent-utilities:/au:ro) — and setPYTHONPATH=/auso the live source overlays the image's installed deps. No editable install (/auis read-only); a*-mcpservice additionally mounts its own package at/srcand setsPYTHONPATH=/au:/src. - Bind-mount the shared XDG config dir as a volume —
/home/genius/.config/agent-utilities:/root/.config/agent-utilities:rowithAGENT_UTILITIES_CONFIG_DIR=/root/.config/agent-utilities— so the containerized service reads the exact sameconfig.json(embedding, backends,GRAPH_DB_URI) as the bare-metal host/parent agent. Every KG client shares this one config, so they share one KG. - Mount the engine socket dir so the service reaches the same engine the
host uses:
/tmp/epistemic-graph.sock:/tmp/epistemic-graph.sock(the committed dev compose mounts the socket directly; the canonical shared layout is the host dir/run/epistemic-graph).
The engine in dev¶
The engine runs the host-built Rust binary inside an ubuntu:26.04 base so
the container glibc matches the host toolchain that built the binary. It serves a
UDS in a shared host dir (/run/epistemic-graph) that is mounted into every
KG client. Bare-metal clients (a debug graph-os, a one-off script) reach the
same socket via GRAPH_SERVICE_SOCKET. The persist-dir holds snapshots;
checkpoint-interval 60 flushes every 60s.
graph-os (MCP surface) — env contract¶
From services/graph-os/compose.dev.yml:
environment:
- TRANSPORT=streamable-http
- PORT=8000
- PYTHONPATH=/au # editable: import agent_utilities from the mount
- KG_DAEMON_ROLE=client # serves MCP; the host daemon is the sibling
- GRAPH_BACKEND=fanout # engine authority + mirrors (was the removed `tiered`)
- AGENT_UTILITIES_CONFIG_DIR=/root/.config/agent-utilities
- MCP_AUTH_TYPE=${MCP_AUTH_TYPE:-jwt} # jwt validates the Keycloak bearer at ingress
- AUTH_JWT_JWKS_URI=http://keycloak.arpa/realms/master/protocol/openid-connect/certs
- AUTH_JWT_ISSUER=http://keycloak.arpa/realms/master
- AUTH_JWT_AUDIENCE=agent-services
volumes:
- /home/apps/workspace/agent-packages/agent-utilities:/au:ro
- /tmp/epistemic-graph.sock:/tmp/epistemic-graph.sock
- /home/genius/.config/agent-utilities:/root/.config/agent-utilities:ro
- agent_utilities_data:/root/.local/share/agent-utilities # engine snapshots / cache
command:
- sh
- -c
- "pip install --no-cache-dir neo4j falkordb redis 2>&1 | tail -1; exec python -m agent_utilities.mcp.kg_server"
Mirror-driver install caveat: the base image carries the core deps, but the
mirror drivers (neo4j, falkordb, redis) that write-fanout needs are not
in the base image — the dev command installs the light wheels at start before
launching. The first boot is therefore slow; start_period: 120s on the
healthcheck accounts for it.
MCP_AUTH_TYPE=jwt validates every client's Keycloak JWT so each request runs
under a per-user ActorContext and the fail-closed ActionPolicy gate
enforces per-principal permissions (OS-5.14) — the graph-os peer of the
multiplexer's Eunomia. For a day-0 bootstrap before Keycloak is up, set
MCP_AUTH_TYPE=none in the stack env. KG_SERVED_PROFILE=0 lets a standalone
debug graph-os accept local unauthenticated inbound calls.
graph-os-host (the background plane)¶
Identical mounts and config, but KG_DAEMON_ROLE=host, no served port, and
it launches python -m agent_utilities.gateway.daemon. It owns the host-lock
(its healthcheck asserts effective_daemon_role() == "host"), runs the
maintenance scheduler + task/ingest workers + embed-backfill, and fans writes out
to the mirrors. It replaces the bare-metal agent-utilities-gateway.service.
mcp-multiplexer (dev)¶
MCP_MULTIPLEXER_MODE=dynamic — only meta-tools + always-on servers are mounted;
the rest load on demand. It reads MCP_CONFIG=/mcp_config_central.json (the
fleet config) and EUNOMIA_POLICY_FILE=/eunomia_policy.json (both bind-mounted).
JWT identity at ingress sets the principal to the token azp (e.g.
claude-code). In dev the JWKS/issuer/audience and MCP_AUTH_TYPE/EUNOMIA_TYPE
are supplied via the stack env as plain ${VAR} (not ${VAR:-default} —
Portainer string-stacks don't honour the default form).
The *-mcp fleet (dev)¶
Each connector is one editable service. From services/github-mcp/compose.dev.yml:
it bind-mounts its own package at /src and agent-utilities at /au, sets
PYTHONPATH=/au:/src, builds an installable copy at start
(cp -a /src/. /tmp/pkg && pip install /tmp/pkg && exec github-mcp), and is
fronted with AUTH_TYPE=jwt + EUNOMIA_TYPE=remote
(EUNOMIA_REMOTE_URL=http://eunomia.arpa). Connector creds (GITHUB_TOKEN, …)
come from the stack env.
Deploying the dev stacks¶
Deploy from the R820 Swarm manager (RW710 is a worker). Because the services
bind-mount host paths, they are pinned to the node holding the bind-mounts
via a placement constraint (node.labels.name == ${SERVER:-RW710}):
# from the R820 swarm manager
docker stack deploy -c services/graph-os/compose.dev.yml graph-os
docker stack deploy -c services/mcp-multiplexer/compose.dev.yml mcp-multiplexer
docker stack deploy -c services/github-mcp/compose.dev.yml github-mcp
# edit code on the host, then restart the service to pick it up live:
docker service update --force graph-os_graph-os
docker service update --force graph-os_graph-os-host
The dev stacks pin every service to the node that physically holds the source tree + XDG config + engine socket. If you scale a bind-mounted service across nodes, the mounts won't exist on the other nodes — keep dev replicas on the pinned node, or move to the production (no-bind-mount) model.
Production deployment¶
Production swaps bind-mounts for built, pinned images and injects secrets from OpenBao. Nothing editable, nothing secret in the repo.
Images, not mounts¶
Each service runs a versioned image pulled from the private registry
(docker.io/knucklessg1/<service>:<tag> per
deploy/mcp-fleet.registry.yml) — no
/au//src bind-mount, no pip-install-at-start. The engine runs as a built
engine image (not the host-binary-in-ubuntu trick). GitOps is Portainer off
gitlab.arpa: the stack source is a git repo, Portainer redeploys on change.
Secrets from OpenBao (never committed)¶
Secrets live in OpenBao at apps/<service> (KV v2, mount apps/) and are
injected into each stack's env at deploy time. The repo .env holds only
non-secret defaults and the names of vars. Retrieve with bao kv get
apps/<service> or openbao-mcp; the per-service OPENBAO_TOKEN carries the
agent-apps-rw policy scoped to apps/data/*. Key secrets per service:
| Secret | Used by | Purpose |
|---|---|---|
OIDC_CLIENT_ID / OIDC_CLIENT_SECRET / OIDC_AUDIENCE / OIDC_TOKEN_URL (+ MCP_CLIENT_AUTH=oidc-client-credentials) |
multiplexer, messaging daemon, every spawned agent | Keycloak client-credentials bearer to reach the jwt-protected fleet |
GRAPH_DB_URI |
host daemon, graph-os | Postgres KG mirror DSN |
STATE_DB_URI |
host daemon, graph-os | durable checkpoints/sessions/queue Postgres |
provider keys (LLM_API_KEY, ANTHROPIC_API_KEY, …) |
graph-os, messaging, connectors | model inference |
TELEGRAM_BOT_TOKEN (+ Slack/Mattermost/…) |
messaging daemon | enable each chat backend |
connector creds (GITHUB_TOKEN, GITLAB_TOKEN, …) |
the matching *-mcp |
reach the external system |
OPENBAO_TOKEN |
every service | read its own apps/<service> secrets |
The messaging daemon specifically loads the OIDC client-credentials into its
process env at startup (sourced from apps/mcp-multiplexer in OpenBao) so the
multiplexer it spawns — and every nested graph_orchestrate-spawned agent —
authenticate to the fleet. These creds are never put in a plaintext config or
.env file (see Messaging Reach).
Multi-user auth, edge, engine, queue¶
- JWT + Eunomia for multi-user. Keycloak mints the bearer; the multiplexer
validates it and maps the azp principal to an Eunomia identity. The Eunomia
policy is zero-trust default-deny: a
_base-meta-toolsrule grants every authenticated principal only the discovery meta-tools (find_tools,list_catalog,load_tools,unload_tools,multiplexer_status), and per-client rules grant actual capability. Onboard a new client (mint the Keycloak client + append its allow rule) with themcp-client-onboarderskill. graph-os enforces the peer control: JWT →ActorContext→ per-principal ActionPolicy. - Caddy edge. All ingress is the global Caddy reverse proxy publishing the
.arpadomains (graph-os.arpa,mcp-multiplexer.arpa,<svc>.arpa) with HTTPS termination. - Engine via socket-volume or TCP sharding. Single-host prod can share the
engine UDS through a host-dir volume; scale-out exposes the engine over TCP
(
--tcp-addr) and clients address shards viaGRAPH_SERVICE_ENDPOINTS(e.g.tcp://engine:9090) with client-side HRW routing by tenant — see Tenant-Partitioned Engine Sharding. - Durable Postgres queue/state.
STATE_DB_URIexternalizes checkpoints/sessions/queues onto shared Postgres withSKIP LOCKEDclaims and advisory-lock daemon leadership;TASK_QUEUE_BACKENDselects the durable queue the host daemon drains — see Durable-State Externalization and Queue-Driven Agent Dispatch.
Health, placement, scaling¶
Each service ships a healthcheck (HTTP /health for graph-os, a socket connect
for the multiplexer, the host-lock assertion for the daemon). With images (no
bind-mount pin) you can place by capacity and scale each service
independently: replicate graph-os and the *-mcp connectors behind Caddy;
keep graph-os-host a singleton (host-lock); shard the engine by adding
endpoints. Reactive autoscaling is declared per-service in the
FLEET_DESIRED_STATE_PATH override (see the scaling block in
deploy/mcp-fleet.registry.yml) and acted
on by the leader-only autoscaler tick.
Day-0 bring-up¶
Stand the whole thing up from bare hosts with the agent-os-genesis skill:
it resolves the adaptive run plan (orchestrator, IdP, secrets store, ontology
host), brings up Swarm + Vault + DNS + SSO + Caddy ingress + observability, then
deploys the *-mcp fleet from
deploy/mcp-fleet.registry.yml. The
deploy/swarm/graphos.stack.yml and deploy/k8s/graphos.yaml manifests carry
the same env contract as the dev compose, downscaled/upscaled by replica
count and placement only.
Migration note — from systemd to zero-systemd¶
The platform previously ran as three bare-metal systemd units on the source host:
| Old systemd unit | Now |
|---|---|
epistemic-graph.service |
engine container (dev: host-built binary in ubuntu:26.04; prod: built engine image), UDS exported via a shared host dir / TCP shards |
agent-utilities-gateway.service |
graph-os-host (gateway.daemon, KG_DAEMON_ROLE=host) container — the background plane — plus graph-os (kg_server, role client) container for the MCP surface (the one unit was split into the host daemon and the served surface) |
agent-utilities-messaging.service |
messaging daemon (messaging.daemon) container |
The move is a strangle-then-delete: the containers read the same
config.json (the shared XDG mount) and the same engine socket, so they join
the identical KG the systemd units served — no data migration. Once the stacks
are healthy, the systemd units are removed entirely. Result: zero systemd —
the whole KG platform is containerized, GitOps-managed via Portainer, and
restartable/redeployable per service without touching the host's init system.