Architektur-Skelett
Dev-Fassung mit Code-Walks — geprüft gegen wintermute@47ec498 (Stand 2026-07-03).
TL;DR: Ein selbstgebauter Agent besteht aus fünf entkoppelten Schichten: Channels (woher kommt die Nachricht), Agent-Core (Persönlichkeit + Gesprächs-Loop), MCP-Gateway (Werkzeug-Zugriff), Vektor-Store (Langzeit-Gedächtnis) und Embedding-Service (Bedeutung → Zahlen). Wer diese Trennung sauber durchhält, kann jeden Baustein einzeln austauschen.
Das Problem
Wenn man "KI-Agent" hört, denkt man oft an ein einzelnes Programm, das alles macht: chatten, Werkzeuge bedienen, sich erinnern, sich aktualisieren. Genau diese Mono-Architektur ist die häufigste Fehlerquelle:
- Modell-Wechsel zwingt zur Neuschreibung der Memory-Logik
- Neuer Channel (Slack statt Telegram) heißt halbes System umbauen
- Tools brauchen privilegierten Zugriff, weil sie im selben Prozess wie das LLM laufen
- Updates ohne Downtime werden unmöglich
Das eigentliche Problem ist nicht "wir bauen einen Agenten", sondern "wir bauen ein verteiltes System, das so aussieht wie ein Agent". Wer das von Anfang an erkennt und entsprechend trennt, spart sich später viele Refactorings.
Unsere Lösung
Wintermute zerlegt das Ganze in fünf Schichten plus optionalen Adaptern. Jede Schicht ist ein eigener Container mit klar definiertem Auftrag. Dazu kommen zwei read-only-Datenquellen, die vom Host in die Container gemountet werden:
flowchart TD
C["<b>Channels</b><br/>Telegram · WhatsApp · OpenAI-kompat. API<br/><i>Plattform-Spezifika kapseln</i>"]
A["<b>Agent-Core</b><br/>Persönlichkeit · Tool-Use-Loop<br/>Memory-Recall · Modell-Auswahl (LiteLLM)<br/>Push-Queue · Standing Instructions"]
G["<b>MCP-Gateway</b><br/>Tool-Registry<br/>Berechtigungs-Boundary"]
L["<b>LLM-Anbieter</b><br/>Anthropic · Moonshot · OpenAI<br/>Remote-Ollama via Tailscale"]
Q[("<b>Qdrant</b><br/>Vektor-Store")]
E["<b>Ollama-Embeddings</b><br/>nomic-embed-text"]
W["<b>Host-Workspaces</b><br/>Projekt-Repos vom Host<br/>read-only"]
S["<b>Standing Instructions</b><br/>YAML vom Host<br/>read-only"]
C -->|"HTTP POST /chat"| A
C -.->|"pollt Push-Queue"| A
A -->|"HTTP - Tool-Aufrufe"| G
A -->|"HTTPS - LLM-Calls"| L
G --> Q
G --> E
W -.->|"ro-Mount"| G
W -.->|"ro-Mount"| A
S -.->|"ro-Mount"| A
classDef boundary fill:#fef3c7,stroke:#d97706,stroke-width:2px;
classDef hostro fill:#f3f4f6,stroke:#6b7280,stroke-dasharray: 5 5;
class G boundary;
class W,S hostro;
Die gelbe Hervorhebung am MCP-Gateway ist Absicht: das ist die
Sicherheits-Grenze. Alles, was Werkzeuge an deiner echten
Welt anfasst (Dateien, GitHub, E-Mail), muss durch diese eine
Tür. Die gestrichelten grauen Knoten sind Host-Daten, die
ausschließlich read-only in die Container gelangen - dazu unten
mehr.
Wer ist für was zuständig?
- Channels sind dumme Postboten. Sie kennen Telegram-IDs, WhatsApp-Webhooks, OpenAI-Endpunkt-Formate. Sie kennen nicht den Agenten. Tausche Telegram gegen Signal aus - der Rest bleibt unangetastet. Seit Juni 2026 sind die Postboten in beide Richtungen unterwegs: der Telegram-Adapter pollt zusätzlich eine Push-Queue im Agent-Core, damit der Agent von sich aus Nachrichten schicken kann (Details in der Tech-Vertiefung).
- Agent-Core ist das eigentliche Gehirn. Hier wohnt die Persönlichkeit (Kapitel 03), die Memory-Logik (Kapitel 04), die Modell-Auswahl (Kapitel 06) und die Tool-Use-Schleife (Kapitel 05+07). Der Core kennt keine Tools direkt - nur "es gibt einen Gateway, dort kannst du fragen". Zusätzlich liest der Core beim Start Standing Instructions: eine vom Operator von Hand gepflegte YAML-Datei auf dem Host, read-only gemountet - Daueranweisungen, die kein Chat-Turn ändern kann.
- MCP-Gateway ist die Berechtigungs-Grenze. Jedes Werkzeug (Dateisystem-Lesezugriff, GitHub-API, IMAP, Web-Suche, Workspace-Read) wird hier registriert und sandboxed. Der Core kommt nie ohne den Gateway an ein Werkzeug. Das ist Absicht: ein halluzinierender Agent kann ohne Gateway nichts kaputt machen, was der Gateway-Operator nicht freigegeben hat.
- Vektor-Store (Qdrant) speichert semantische Erinnerungen. Pro Gesprächsturn wird ein Embedding (siehe nächster Punkt) gespeichert; bei jedem neuen Turn fragt der Core: "welche vergangenen Turns passen zu dieser Anfrage?"
- Embedding-Service (Ollama mit
nomic-embed-text) übersetzt Text in Zahlenvektoren. Das ist die Grundlage für semantische Suche. Wichtig: der Container-Ollama macht hier nur Embeddings - die LLM-Inferenz läuft beim Anbieter. Neu ist: Chat-Inferenz kann zusätzlich an einen entfernten Ollama gehen (z.B. ein Tailscale-Peer mit GPU), per/local-Slash-Command. Das ist ein weiterer LLM-Anbieter im Abstraktions-Layer, nicht der Embedding-Container.
Warum diese Reihenfolge der Schichten? Weil sie der Daten-Fluss-Richtung folgt: Nachricht kommt rein (Channel), wird interpretiert (Core), löst evtl. Werkzeug-Aufrufe aus (Gateway), die ggf. Daten brauchen (Stores). Antwort fließt denselben Weg zurück.
Trade-offs & offene Fragen
- Vier bis sechs Container ist viel für eine einzelne
Person als Operator. Du brauchst Docker Compose oder Kubernetes
als Orchestrator und musst pro Schicht Logs, Updates und
Backups verstehen. Der Wintermute-Stack ist mit
docker compose up -dstartbar - aber "starten" ist nicht "betreiben". - Netzwerk-Topologie zählt. Wintermute lernte schmerzhaft,
dass Container-interne Services nicht über
localhostauf dem Host erreichbar sind (LESSONS.md§21). Wer beim Bauen diese Trennung nicht versteht, debuggt sich wund. - Tool-Latenz addiert sich. Jeder Hop (Channel → Core → Gateway → Tool → Antwort) ist Millisekunden. In der Praxis ist das egal - aber unter Last (z.B. großer Tool-Aufruf parallel zur LLM-Generierung) merkt man die Architektur. Deshalb sind die HTTP-Timeouts entlang der Kette inzwischen konfigurierbar (Default 300s) - ein langsames Remote-Modell hinter einem Tool riss vorher den ganzen Turn ab.
- Vollständig lokale Variante ist möglich, aber teuer. Du
könntest die LLM-Inferenz auch in den Stack ziehen (über
vLLM oder lokale Ollama-Inferenz). Dann brauchst du
Hardware (8-24 GB VRAM für brauchbare Modelle). Wintermute
geht einen Mittelweg: per
OLLAMA_API_BASEkann ein entfernter Ollama (z.B. der eigene Desktop-Rechner via Tailscale) als zusätzlicher Anbieter angebunden werden - die Hetzner-VM bleibt klein, die GPU steht woanders. - Push ist Polling, nicht Streaming. Proaktive Nachrichten landen in einer In-Memory-Queue, die der Telegram-Adapter alle paar Sekunden abholt. Das ist bewusst simpel: kein zweiter Server im Bot-Prozess, keine Persistenz. Kostet Latenz (Sekunden) und verliert pendende Pushes bei einem Agent-Neustart - für periodische Briefings okay, für At-least-once-Zustellung nicht.
- Was fehlt: Multi-Tenancy. Wintermute ist Single-User-Architektur. Wer mehrere Nutzer mit eigenem Memory und eigenen Rechten braucht, muss die Memory-Layer partitionieren - das ist offene Arbeit (Kapitel 09).
🔧 Tech-Vertiefung
Konkrete Service-Liste aus docker-compose.yml
Stand 2026-07 betreibt Wintermute folgende Container:
| Container-Name | Image | Aufgabe | Port (intern) |
|---|---|---|---|
wintermute-qdrant |
qdrant/qdrant:latest |
Vektor-Store | 6333 |
wintermute-ollama |
ollama/ollama:latest |
Embedding-Inferenz | 11434 |
wintermute-ollama-init |
ollama/ollama:latest |
One-shot: pullt Embedding-Modell beim ersten Start | - |
wintermute-mcp-gateway |
Eigenes Image | Tool-Registry + Berechtigungs-Boundary | 8000 |
wintermute-agent |
Eigenes Image | Agent-Core (Python/FastAPI/LiteLLM) | 8080 |
wintermute-telegram |
Eigenes Image | Telegram-Bot-Adapter + Push-Drainer | - |
Plus ein host-seitiger Updater-Service (systemd/wintermute-updater.service),
der das Selbst-Update HMAC-authentifiziert aus dem Gateway-Container
empfängt. Details in Kapitel 08. Der Grund für die Auslagerung
in Kürze: ein Container, der sich selbst neu bauen kann, muss
Docker-Socket-Zugriff haben - und das bricht die Sandbox. Lieber
den Update-Pfad über einen schmalen, kontrollierten Host-Endpunkt
schicken.
Compose-Auszug: einfache Services (Qdrant, Ollama, Ollama-Init)
Die drei einfachsten Services zeigen das Compose-Basis-Pattern:
# docker-compose.yml (Auszug, Z.19-85)
qdrant:
image: qdrant/qdrant:latest
container_name: wintermute-qdrant
restart: unless-stopped
volumes:
- ${QDRANT_DATA_DIR:-./data/qdrant}:/qdrant/storage
environment:
# Quiet down telemetry; set QDRANT__TELEMETRY_DISABLED=false to re-enable.
- QDRANT__TELEMETRY_DISABLED=true
networks:
- wintermute-net
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
ollama:
# Embeddings only — not used for chat completions.
# On first run, the model pull is performed by the `ollama-init` sidecar.
image: ollama/ollama:latest
container_name: wintermute-ollama
restart: unless-stopped
volumes:
- ${OLLAMA_DATA_DIR:-./data/ollama}:/root/.ollama
healthcheck:
# `ollama list` succeeds once the server is accepting requests (same
# readiness signal the init sidecar waits on). The ollama binary is in
# the image, so no extra tooling is needed.
test: ["CMD", "ollama", "list"]
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
networks:
- wintermute-net
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
ollama-init:
# One-shot init: waits for ollama, pulls the embedding model if missing, exits.
# Idempotent — safe to re-run; `ollama pull` is a no-op when the model already exists.
image: ollama/ollama:latest
container_name: wintermute-ollama-init
depends_on:
ollama:
condition: service_healthy
environment:
- OLLAMA_HOST=http://ollama:11434
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-nomic-embed-text}
entrypoint: ["/bin/sh", "-c"]
command:
- |
echo "[ollama-init] waiting for ollama at $OLLAMA_HOST..."
for i in $(seq 1 60); do
if ollama list >/dev/null 2>&1; then break; fi
sleep 1
done
echo "[ollama-init] pulling $EMBEDDING_MODEL (idempotent)"
ollama pull "$EMBEDDING_MODEL"
echo "[ollama-init] done"
networks:
- wintermute-net
restart: "no"
Vier Dinge sind hier praktisch relevant:
${VAR:-default}-Pattern überall. Compose-Variable-Substitution liest aus.env; das:--Postfix gibt einen Fallback, wenn die Variable leer/unset ist. Ohne den Default scheitertdocker compose configmit einer kryptischen Fehlermeldung.restart: unless-stoppedfür Dauerläufer,restart: "no"für Init-Container. Ohne das würde der Init-Container in einer Schleife immer wieder das Embedding-Modell pullen.- Healthchecks mit Bordmitteln des Images (
LESSONS.md§31): der Ollama-Healthcheck ruftollama list- ein Binary, das im Image sicher existiert. Eincurl- oderwget-basierter Check wäre in schlanken Images schlicht nicht ausführbar und der Container bliebe für immerunhealthy. Die eigenen Python-Images prüfen analog mitpython -c "import urllib.request..."gegen/health. - Logging-Limit pro Service (
max-size: "10m",max-file: "3"). Ohne das fressen sich Container-Logs auf einer Hetzner-VM irgendwann die Disk voll. Default ist unbegrenzt.
Compose-Auszug: der mcp-gateway-Service
Der Gateway ist der komplexeste Service, weil er die Sicherheits- Grenze trägt. Die wichtigsten Stellen mit Original-Kommentaren (die langen ENV-Blöcke für Web-Search, GitHub und IMAP sind gekürzt):
# docker-compose.yml (Auszug, Z.87-208, gekürzt)
mcp-gateway:
image: ${IMAGE_REGISTRY:-}${IMAGE_REGISTRY:+/}wintermute-mcp-gateway:${IMAGE_TAG:-latest}
build: ./mcp-gateway
container_name: wintermute-mcp-gateway
restart: unless-stopped
# Hardened: container is no longer privileged.
# - no Docker socket (was: /var/run/docker.sock)
# - no SSH key mount (was: ${SSH_KEY_PATH}:/app/.ssh:ro)
# - no host-repo r/w mount (was: ${WINTERMUTE_REPO_DIR}:${WINTERMUTE_REPO_DIR})
# - runs as appuser (uid 1000), not root (Dockerfile)
# Self-update now goes through the host wintermute-updater service
# via an HMAC-authenticated HTTP call. See docs/UPDATER.md.
volumes:
- ${MCP_CONFIG_DIR:-./data/mcp-config}:/app/config
- ${MCP_STATE_DIR:-./data/state}:/state
- ${DOCUMENTS_DIR:-./data/documents}:/documents:ro
# Shared workspace — repos stored on the host (e.g. /home/dixie/projects)
# are available inside the container at /workspaces/<repo-name>.
# READ-ONLY (Issue #50): only read tools (workspace_read_file /
# workspace_list_directory) use this mount; there is no write/exec tool.
# A read-write mount would let a hallucinated/compromised agent edit any
# repo under WORKSPACES_DIR, bypassing the GitHub-tool self-mod guards.
# When a write tool is introduced, gate it per #50 before relaxing this.
- ${WORKSPACES_DIR:-/home/dixie/projects}:/workspaces:ro
# Read-only mount of the updater shared-secret file. Host file is
# 0640 root:wintermute-secrets (gid set by WINTERMUTE_SECRETS_GID,
# default 2000). The container's appuser is a member of the same
# numeric gid, so the read works without making the file
# world-readable.
- ${UPDATER_TOKEN_FILE:-/etc/wintermute/updater.token}:/run/secrets/updater_token:ro
# IMAP accounts file (issue #24). Read-only mount. Contains
# credentials; treat with the same care as updater.token.
- ${IMAP_ACCOUNTS_FILE_HOST:-/dev/null}:/run/secrets/imap-accounts.json:ro
# Add the host's wintermute-secrets gid to the container's appuser at
# runtime. Required when the gid baked into the image (2000) doesn't
# match the host's. Override via WINTERMUTE_SECRETS_GID in .env.
group_add:
- "${WINTERMUTE_SECRETS_GID:-2000}"
environment:
- MCP_PORT=8000
# Deployed commit for status/version reporting. The gateway image has no
# git binary or repo mount, so get_current_commit() reads this env first.
# Export GIT_COMMIT=$(git rev-parse --short HEAD) before `compose up` to
# populate it (the host updater does this on rebuild).
- GIT_COMMIT=${GIT_COMMIT:-}
# --- Workspace mount (feat: workspace read tools) ---
- WORKSPACES_ROOT=${WORKSPACES_ROOT:-/workspaces}
# --- Self-update via host updater ---
- UPDATER_URL=${UPDATER_URL:-http://host.docker.internal:9100}
- UPDATER_TOKEN_FILE=/run/secrets/updater_token
- UPDATER_HTTP_TIMEOUT_SECONDS=${UPDATER_HTTP_TIMEOUT_SECONDS:-600}
# --- Memory subsystem ---
- MEMORY_ENABLED=${MEMORY_ENABLED:-true}
- QDRANT_URL=${QDRANT_URL:-http://qdrant:6333}
- QDRANT_COLLECTION=${QDRANT_COLLECTION:-wintermute-memory}
- EMBEDDING_PROVIDER=${EMBEDDING_PROVIDER:-ollama}
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-nomic-embed-text}
- EMBEDDING_URL=${EMBEDDING_URL:-http://ollama:11434}
- EMBEDDING_DIM=${EMBEDDING_DIM:-768}
# ...weitere ENV-Blöcke für web-search (inkl. SSRF-Guard-Limits),
# github und imap - gekürzt, siehe Repo...
# Allow the gateway to reach the host updater on Linux without exposing
# the updater externally; on Docker Desktop this is automatic.
extra_hosts:
- "host.docker.internal:host-gateway"
healthcheck:
# python is in the slim image; urllib avoids needing curl/wget.
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/health', timeout=3).status==200 else 1)"]
interval: 10s
timeout: 5s
retries: 6
start_period: 20s
depends_on:
# qdrant starts fast and the client tolerates brief unavailability, so
# it stays service_started. Embeddings, however, must be pulled before
# the gateway runs its first recall — gate on the init sidecar finishing.
qdrant:
condition: service_started
ollama:
condition: service_healthy
ollama-init:
condition: service_completed_successfully
networks:
- wintermute-net
Vier Patterns, die in diesem einen Service zusammenkommen und die für die Architektur prägend sind:
- Bewusstes Nicht-Mounten als Sicherheits-Statement. Der Block-Kommentar oben listet auf, was früher gemountet war und bewusst entfernt wurde - Doku, die Compose-File und PR-Kontext gleichzeitig ist. Dasselbe Muster beim Workspace-Mount: der Kommentar erklärt, warum
:ronicht verhandelbar ist, solange es kein gegatetes Write-Tool gibt. Wer auf die Schnelle einen Docker-Socket oder einrwreinhängt, sieht den Kommentar und denkt nochmal nach. - Secrets als File-Mount mit Gruppen-Berechtigung statt ENV-Var. Der
updater_tokenund dieimap-accounts.jsonliegen als Datei vor (mode0640, Gruppewintermute-secrets), und der Container-User wird pergroup_addMitglied dieser Gruppe. ENV-Variablen sind indocker inspectund in Process-Trees sichtbar; File-Mounts nicht. host.docker.internalüberextra_hosts: host-gateway. Auf Linux gibt es kein automatisches Mapping vonhost.docker.internalauf den Host (das ist Docker-Desktop-Magie für macOS/Windows). Dieextra_hosts-Zeile zwingt Docker, das auf die Docker-Bridge-IP aufzulösen, sodass der Gateway-Container den Host-Updater erreicht.depends_onmitconditionstatt nacktemdepends_on. Ein nacktesdepends_onsagt nur "starte den anderen Container vorher", nicht "warte bis er bereit ist". Seit dem Robustness-Pass im Juni 2026 nutzt Wintermute echte Startup-Gates:service_healthygegen den Ollama-Healthcheck,service_completed_successfullygegen den Init-Sidecar (das Embedding-Modell muss gepullt sein, bevor der Gateway seinen ersten Recall fährt). Qdrant bleibt bewusstservice_started- der Client toleriert kurze Nichtverfügbarkeit.
Workspace-Mount: Host-Repos read-only im Stack
Neu seit Juni 2026: das Projekte-Verzeichnis des Hosts (z.B.
/home/dixie/projects) ist als /workspaces in beide
Container (Gateway und Agent) gemountet - strikt :ro. Der
Gateway registriert dafür zwei Tools:
| Tool | Zweck |
|---|---|
workspace_read_file(path) |
Einzelne Datei unterhalb WORKSPACES_ROOT lesen |
workspace_list_directory(path) |
Verzeichnis unterhalb WORKSPACES_ROOT listen |
Zwei Eigenschaften sind architektonisch wichtig:
- Read-only auf zwei Ebenen. Es existiert kein Write-/Exec-Tool,
und der Mount selbst ist
:ro. Selbst wenn ein Tool-Bug direkten Dateisystem-Zugriff erlaubte, könnte der Container keine Host-Repos verändern - sonst ließe sich der Self-Modification-Guard der GitHub-Tools (Kapitel 05) über das Dateisystem umgehen. - Harte Pfad-Grenze, keine Policy. Path-Traversal-Versuche
(
../../../etc/passwd) werden im Tool per echtem Pfad-Boundary-Check abgewiesen, nicht per String-Vergleich. Details:docs/WORKSPACE-TOOLS.md.
Compose-Auszug: der agent-Service
Der Agent-Core ist das einzige Service-Modul, das auf zwei Netzen sitzt - innen erreichbar für Telegram, von außen durchgeleitet für die OpenAI-kompatible API. Auszug (Memory- Feintuning-Variablen gekürzt):
# docker-compose.yml (Auszug, Z.210-324, gekürzt)
agent:
image: ${IMAGE_REGISTRY:-}${IMAGE_REGISTRY:+/}wintermute-agent:${IMAGE_TAG:-latest}
build: ./agent
container_name: wintermute-agent
restart: unless-stopped
# No host port mapping. External access for /v1/* goes through
# the reverse proxy on `proxy-network` (see docs/OPENAI-COMPAT.md).
# The internal /chat and /docs surfaces stay on `wintermute-net`
# only — reachable by the telegram container, not by the proxy.
volumes:
- ${MEMORY_DIR:-./data/memory}:/app/memory
# Shared workspace — same mount as mcp-gateway. READ-ONLY (Issue #50):
# the agent reaches workspace files only via the gateway's read tools and
# has no write/exec tool, so it never needs write access here.
- ${WORKSPACES_DIR:-/home/dixie/projects}:/workspaces:ro
# Standing Instructions (#68, Option D): a YAML file you maintain by hand
# on the host, mounted READ-ONLY. The agent only reads it. Defaults to
# /dev/null so the line is valid until you point it at a real file.
- ${STANDING_INSTRUCTIONS_FILE_HOST:-/dev/null}:/run/config/standing-instructions.yaml:ro
environment:
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- MOONSHOT_API_KEY=${MOONSHOT_API_KEY:-}
- DEFAULT_MODEL=${DEFAULT_MODEL:-anthropic/claude-opus-4-7}
- THINKING_MODEL=${THINKING_MODEL:-moonshot/kimi-k2.6}
# Default matches .env.example. Ollama is embeddings-only here and is
# kept separate from the work/chat models, so the default FAST_MODEL is a
# remote model rather than ollama/* (which would silently need
# OLLAMA_API_BASE). Set FAST_MODEL=ollama/<model> + OLLAMA_API_BASE in
# .env to route /fast at a remote Ollama (e.g. a Tailscale peer).
- FAST_MODEL=${FAST_MODEL:-moonshot/kimi-k2.5}
# --- Remote Ollama for /fast model (LiteLLM standard variables) ---
# Set OLLAMA_API_BASE to a Tailscale peer or other remote Ollama.
# OLLAMA_API_KEY is optional (most self-hosted Ollama don't need one).
# NOTE: local wintermute-ollama is for embeddings only (mcp-gateway
# EMBEDDING_URL). Do not point OLLAMA_API_BASE at it.
- OLLAMA_API_BASE=${OLLAMA_API_BASE:-}
- OLLAMA_API_KEY=${OLLAMA_API_KEY:-}
- ALLOWED_MODELS=${ALLOWED_MODELS:-}
- LOCAL_MODELS=${LOCAL_MODELS:-}
- MCP_GATEWAY_URL=http://mcp-gateway:8000
- AGENT_PORT=8080
# Timeout for agent → gateway tool calls. Generous by default because a
# tool may drive a slow remote model; see incident #35. (config.py
# default is 300s; wired here so it's tunable without a rebuild.)
- AGENT_TOOL_HTTP_TIMEOUT_SECONDS=${AGENT_TOOL_HTTP_TIMEOUT_SECONDS:-300}
- CONVERSATION_HISTORY_LIMIT=${CONVERSATION_HISTORY_LIMIT:-20}
# Standing Instructions (#68): container path the agent reads. Empty/
# /dev/null = feature off. Pairs with the :ro mount above.
- STANDING_INSTRUCTIONS_FILE=${STANDING_INSTRUCTIONS_FILE:-/run/config/standing-instructions.yaml}
# --- Memory subsystem ---
- MEMORY_ENABLED=${MEMORY_ENABLED:-true}
- MEMORY_TOP_K=${MEMORY_TOP_K:-5}
- MEMORY_MIN_SCORE=${MEMORY_MIN_SCORE:-0.6}
# ...Memory-Save-Filter-Variablen gekürzt, siehe Kapitel 04...
# --- Agent loop reliability ---
- MAX_TOOL_ITERATIONS=${MAX_TOOL_ITERATIONS:-25}
- AGENT_SELF_CORRECTION_ATTEMPTS=${AGENT_SELF_CORRECTION_ATTEMPTS:-1}
- DUPLICATE_TOOL_CALL_LIMIT=${DUPLICATE_TOOL_CALL_LIMIT:-2}
# --- HTTP bearer-token gating ---
# Single token gates ALL HTTP routes: /v1/* (OpenAI-compat
# adapter, mounted only when token is set) plus /chat (native
# endpoint, dependency is a no-op when token is unset).
- WINTERMUTE_OPENAI_TOKEN=${WINTERMUTE_OPENAI_TOKEN:-}
# FastAPI auto-generated /docs, /redoc, /openapi.json. Off by
# default since issue #41 (public surface leaked route inventory).
- WINTERMUTE_DOCS_ENABLED=${WINTERMUTE_DOCS_ENABLED:-}
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8080/health', timeout=3).status==200 else 1)"]
interval: 10s
timeout: 5s
retries: 6
start_period: 20s
depends_on:
mcp-gateway:
condition: service_healthy
networks:
# Internal: telegram + the rest of the wintermute services.
- wintermute-net
# External: the host's nginx-proxy-manager reverse proxies
# /v1/* into us via this network. Only the proxy is here, not
# the public internet — still requires the proxy host to be
# configured for the agent.<your-domain> hostname.
- proxy-network
Beachte: der Agent hat keinen ports:-Block. Es gibt kein
8080:8080-Mapping nach außen. Erreichbarkeit von außen geht
ausschließlich über den Reverse-Proxy (in der Wintermute-
Referenz: Nginx Proxy Manager), der auf proxy-network
hängt und die Bearer-Token-Auth durchsetzt. Das ist eine
absichtliche zweite Sicherheits-Schicht: selbst wenn ein
Provider-API-Key durchsickert, kommt niemand ohne den NPM-Host
an den Agenten ran.
Drei jüngere Ergänzungen in diesem Service, jeweils mit eigener Architektur-Bedeutung:
- Standing Instructions (
/run/config/standing-instructions.yaml,:ro): eine vom Operator von Hand gepflegte YAML-Datei auf dem Host, die der Agent beim Start liest und in den System-Prompt einbaut. Der Default/dev/nullhält die Compose-Zeile gültig, solange das Feature aus ist. Wichtig: der Agent kann die Datei nicht schreiben - Daueranweisungen ändern heißt Host-Datei editieren, nicht den Agenten überreden. - Remote-Ollama als LLM-Anbieter (
OLLAMA_API_BASE/OLLAMA_API_KEY, LiteLLM-Standard-Variablen): zeigt auf einen Tailscale-Peer oder anderen entfernten Ollama.LOCAL_MODELSdefiniertalias=modell-Mappings für den/local-Slash-Command; leer heißt Feature aus. Der lokalewintermute-ollama-Container bleibt embeddings-only. - Konfigurierbare Tool-Timeouts
(
AGENT_TOOL_HTTP_TIMEOUT_SECONDS, Default 300s): ein Tool kann seinerseits ein langsames Remote-Modell treiben; der alte, hart kodierte kurze Timeout riss solche Turns ab. Der Telegram-Adapter hat das GegenstückAGENT_HTTP_TIMEOUT_SECONDS(ebenfalls Default 300s).
Proaktive Pushes: der Agent meldet sich selbst
Bisher konnte der Stack nur antworten. Seit Juni 2026 gibt es ein Push-Primitiv: der Agent (und später Scheduler-Jobs wie ein Morgen-Briefing) kann Telegram-Nachrichten initiieren. Die Architektur-Entscheidung dahinter: statt eine Rückwärts- Verbindung zum Bot aufzubauen (zweiter Server im Bot-Prozess), dreht Wintermute die Richtung um -
- Der Agent hält eine bounded In-Memory-Queue
(
deque(maxlen=100)inagent/src/api/routers/push.py).POST /pushlegt eine Nachricht hinein,GET /push/pendinggibt alle pendenden Nachrichten zurück und leert die Queue atomar. Beide Routen sind über denselben Bearer-Token geschützt wie der Rest der HTTP-Fläche. - Der Telegram-Bot pollt
/push/pendingallePUSH_POLL_SECONDS(Compose:TELEGRAM_PUSH_POLL_SECONDS, Default 20s; 0 schaltet den Drainer ab) und stellt zu. Er hat ohnehin schon HTTP-Client und Token zum Agenten. - Die Empfänger-Auflösung lebt im Telegram-Adapter. Ein Push
trägt entweder eine explizite
chat_idodernull("Default-Empfänger"); der Bot löstnullauf seine allow-listed Nutzer auf und verweigert jedechat_idaußerhalb der Allowlist. Der Agent muss nie wissen, wer die Nutzer sind - und ein Push kann konstruktionsbedingt nur allow-listed Chats erreichen.
Der Preis: pendende Pushes gehen bei einem Agent-Neustart verloren (In-Memory), und die Zustell-Latenz ist der Poll-Intervall. Für periodische Briefings reicht das; wer At-least-once braucht, muss Persistenz nachrüsten. Der zugehörige Telegram-Auszug:
# docker-compose.yml (Auszug, Z.326-359, gekürzt)
telegram:
image: ${IMAGE_REGISTRY:-}${IMAGE_REGISTRY:+/}wintermute-telegram:${IMAGE_TAG:-latest}
build: ./channels/telegram
container_name: wintermute-telegram
restart: unless-stopped
environment:
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
- AGENT_URL=http://agent:8080
- WINTERMUTE_OPENAI_TOKEN=${WINTERMUTE_OPENAI_TOKEN:-}
- ALLOWED_USER_IDS_RAW=${ALLOWED_USER_IDS:-}
# Opt-in to allow ANY user when ALLOWED_USER_IDS is empty (otherwise the
# bot fails closed). The pydantic field is ALLOW_ALL_USERS; bridge it
# from the friendlier TELEGRAM_ALLOW_ALL_USERS knob in .env.
- ALLOW_ALL_USERS=${TELEGRAM_ALLOW_ALL_USERS:-false}
- BOT_NAME=${BOT_NAME:-Wintermute}
# Raise when FAST_MODEL uses a slow remote/local model (e.g. Ollama).
- AGENT_HTTP_TIMEOUT_SECONDS=${AGENT_HTTP_TIMEOUT_SECONDS:-300}
# Proactive push (#68 Phase 2): how often the bot drains the agent's
# outbound push queue. 0 disables the drainer.
- PUSH_POLL_SECONDS=${TELEGRAM_PUSH_POLL_SECONDS:-20}
depends_on:
agent:
condition: service_healthy
networks:
- wintermute-net
Zwei Docker-Netze
# docker-compose.yml (Auszug, Z.9-15)
networks:
# Internal — all services talk here
wintermute-net:
driver: bridge
# External — NPM reaches whatsapp adapter for webhooks (optional)
proxy-network:
external: true
Wer hängt wo dran?
| Service | wintermute-net |
proxy-network |
|---|---|---|
| qdrant | ✅ | - |
| ollama | ✅ | - |
| ollama-init | ✅ | - |
| mcp-gateway | ✅ | - |
| agent | ✅ | ✅ |
| telegram | ✅ | - |
| whatsapp (geplant) | ✅ | ✅ |
Nur Services, die direkt von außen erreichbar sein müssen,
sind auf proxy-network. Alles andere ist eine reine
Innenwelt, die durch keinen Reverse-Proxy-Eintrag erreichbar
ist - selbst wenn jemand den NPM kompromittierte, käme er an
Qdrant oder den Gateway nicht heran.
proxy-network ist als external: true deklariert. Das
heißt: das Netz muss außerhalb dieses Compose-Stacks
existieren - typischerweise wird es vom NPM angelegt
(docker network create proxy-network), und der Stack hängt
sich nur dran. So können mehrere unabhängige Compose-Stacks
(Wintermute, finn, andere Tools) denselben Reverse-Proxy
teilen.
Service-Boundary-Disziplin
Die Trennung wird durch sechs Konventionen erzwungen:
- Kein Container kennt einen anderen über Host-IPs — nur über Service-Namen (
http://mcp-gateway:8000,http://qdrant:6333). Compose macht die DNS-Auflösung pro Netzwerk;ping mcp-gatewayaus dem Agent-Container funktioniert,ping 172.18.0.4(oder welche IP das gerade ist) ist unzuverlässig, weil sich die IPs bei jedem Restart ändern können. mcp-gatewayhat keinen Docker-Socket-Zugriff mehr (war früher anders, sieheLESSONS.md§5 + §9). Self-Update geht ausschließlich über den Host-Updater.- Secrets sind File-Mounts mit Gruppen-Berechtigung (
0640 root:wintermute-secrets) — nicht ENV-Vars und nicht world-readable (LESSONS.md§17). Im Compose oben sichtbar anUPDATER_TOKEN_FILE,IMAP_ACCOUNTS_FILE_HOST, plus demgroup_add-Block, der den Container in diewintermute-secrets-Gruppe nimmt. - Host-Daten Richtung Container sind read-only. Alles, was vom Host in einen Container gemountet wird und nicht dessen eigener State ist, trägt
:ro— Dokumente, Workspaces, Standing Instructions, Secret-Files. Schreibzugriff auf den Host bekommt kein Container; der einzige Weg, den Host zu verändern, ist der schmale, allowlisted Updater-Endpunkt. - Compose passt ENV-Vars nicht automatisch durch — jede Variable muss explizit unter
services.X.environment:stehen, sonst Default (LESSONS.md§20). Beispiel-Falle: ein neuer Memory-Save-Filter wird inagent/src/agent/config.pydeklariert, in.env.exampledokumentiert — aber wenn die Zeile indocker-compose.ymlunterservices.agent.environment:fehlt, sieht der Container die Variable nie. Symptom: Default-Werte gelten, obwohl die.env-Datei sauber gesetzt ist. - Persistente Daten sind Host-Mounts, nicht Volumes. Damit ist Backup ein
tar czf data.tar.gz ./data/— keinedocker volume-Akrobatik. Im Compose oben sichtbar als${QDRANT_DATA_DIR:-./data/qdrant}:/qdrant/storage, analog für Ollama, Memory, Gateway-State.
Host-seitiger Updater-Service
Der Updater liegt außerhalb der Container-Welt, als regulärer systemd-Service auf dem Host. Volle Architektur in Kapitel 08. Hier nur der Auszug, der die Anbindung an den Container-Stack zeigt:
# systemd/wintermute-updater.service (Auszug, Z.1-50)
[Unit]
Description=Wintermute Updater (host-side deploy service)
Documentation=file:///opt/wintermute/docs/UPDATER.md https://github.com/juergenvh/wintermute/blob/main/docs/UPDATER.md
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=simple
User=root
Group=root
# Bind to the docker bridge gateway IP, NOT 0.0.0.0.
#
# Why not 127.0.0.1: on Linux the gateway container reaches the host via
# the docker bridge gateway IP (resolved through
# `host.docker.internal:host-gateway` in compose), which is *not* the
# host's loopback — so 127.0.0.1 would make the updater unreachable from
# the container.
#
# Why not 0.0.0.0: this service runs as root and exposes /update, which
# runs `git reset --hard` + `docker compose up --build`. Binding to all
# interfaces puts a root-equivalent endpoint on every NIC (incl. public
# ones on a cloud host), leaving only the token + an out-of-band firewall
# between the internet and host RCE. Binding to the bridge gateway keeps
# it reachable from containers but off the public interfaces by default.
#
# 172.17.0.1 is Docker's default-bridge gateway (what host-gateway resolves
# to on a standard Linux install). If your gateway container runs on a
# user-defined network with a different bridge subnet, override this with
# that network's gateway IP via `systemctl edit wintermute-updater`. On
# Docker Desktop (macOS/Windows) host.docker.internal is loopback — use
# 127.0.0.1 there.
#
# Defense in depth still holds on top of this: HMAC token auth on every
# authenticated endpoint, the host firewall (ufw blocking 9100), and the
# branch + service allow-lists.
Environment=UPDATER_HOST=172.17.0.1
Environment=UPDATER_PORT=9100
# Where the wintermute repo lives on this host.
Environment=WINTERMUTE_REPO_DIR=/opt/wintermute
Environment=WINTERMUTE_COMPOSE_FILE=/opt/wintermute/docker-compose.yml
# Token file (mode 0600, written by install-updater.sh).
Environment=UPDATER_TOKEN_FILE=/etc/wintermute/updater.token
# Allow-lists. Override here if you want to deploy a different branch
# or add a service to the rebuildable set.
Environment=UPDATER_ALLOWED_BRANCHES=main
Environment=UPDATER_ALLOWED_SERVICES=mcp-gateway,agent,telegram,qdrant,ollama,ollama-init
Zwei Stellen tragen hier das Sicherheits-Statement. Erstens die
Allowlists: der Updater darf nur den main-Branch deployen,
und nur die sechs allowlisted Services neu bauen. Ein
kompromittierter Gateway-Container kann den Updater rufen so
oft er will - er kann nicht "aus Versehen" einen anderen
Branch oder einen anderen Service triggern. Zweitens das
Binding: ein früherer Stand band den Updater an 0.0.0.0 und
verließ sich auf Token plus Firewall. Beim Security-Hardening
im Juni 2026 wurde das auf die Docker-Bridge-Gateway-IP
(172.17.0.1) umgestellt - der Endpunkt bleibt aus den
Containern erreichbar, liegt aber per Default auf keinem
öffentlichen Interface mehr. Der lange Kommentar im Unit-File
dokumentiert genau diese Abwägung. Mehr Details in Kapitel 08.
Wenn du anders bauen willst
Die Schichtung ist konzeptionell universal, aber die konkrete Implementierung hat Alternativen:
| Wintermute-Wahl | Alternative |
|---|---|
| Docker Compose | Kubernetes, Nomad, systemd-only |
| Qdrant | Weaviate, ChromaDB, pgvector, Lance |
| Ollama (Embeddings) | OpenAI text-embedding-3-small, Cohere, fastembed |
| LiteLLM (Multi-Provider) | Direkt-SDKs pro Anbieter, OpenRouter |
| MCP-Gateway (eigenes Python-Projekt) | Offizielle MCP-Reference-Implementierung, LangChain-Tools, n8n |
| Telegram als primärer Channel | Discord, Signal, Matrix, WhatsApp, IRC |
| Push via Poll-Queue | Webhook zum Bot, Message-Broker (Redis, NATS) |
Die Trennung in Schichten bleibt - das, was darin steckt, ist austauschbar. Genau das ist das Designziel.
📚 Quellen im Wintermute-Repo
README.md- das offizielle Architektur-Diagramm und der Service-Stack
docker-compose.yml- die wahre Quelle: was läuft wo, mit welchen Mounts und Env-Vars
systemd/wintermute-updater.service- die systemd-Unit mit Bridge-IP-Binding und den Branch- und Service-Allowlists
docs/SECURITY.md- Trust-Modell, warum welche Schicht welchen Zugriff hat;
inkl. SSRF-Guard in
web_fetchund Fail-closed/Fail-open-Defaults
- Trust-Modell, warum welche Schicht welchen Zugriff hat;
inkl. SSRF-Guard in
docs/WORKSPACE-TOOLS.md- Workspace-Read-Tools, Pfad-Grenzen, das Zwei-Ebenen-Read-only
docs/UPDATER.md- der host-seitige Updater-Service und der Grund seiner Existenz
agent/src/api/routers/push.py- das Push-Primitiv mit den Design-Notes im Modul-Docstring
LESSONS.md§10 (Container-Namen), §17 (Secret-Mount-Stolperfallen), §20 (Compose-ENV-Passthrough), §21 (Internal-only Services nicht vialocalhost), §31 (Healthchecks mit Bordmitteln des Images)