Einen projektspezifischen KI-Assistenten über Telegram bauen
Ein KI-Assistent, der auf ein einziges Kundenmandat beschränkt ist, unterscheidet sich strukturell von einem organisationsweiten KI-Tool. Andere Grenzen, ein anderes Memory-Modell, andere Secrets, ein anderer Deployment-Lebenszyklus. Dieser Leitfaden beschreibt das projektspezifische KI-Assistenten-Muster, das wir bei tva verwenden: ein Telegram-Bot pro Beratungsmandat, jeder mit einer eigenen Claude Code CLI-Instanz mit persistentem OAuth, einem eigenen Workspace und einer eigenen Teilnehmerliste. Das Muster lässt sich als Template auf andere Mandate übertragen, und der gesamte Stack läuft in einem einzigen Docker-Container.
Dieser Leitfaden ist so geschrieben, dass ein Entwickler (oder ein LLM) ihn linear lesen und das Setup von Grund auf neu nachbauen kann. Jede Version ist gepinnt. Jede Konfigurationsentscheidung ist begründet. Jede Architekturentscheidung listet die verworfenen Alternativen und deren Gründe auf.
Was du brauchst
- Einen Linux-Server mit Docker Engine 29.x und Docker Compose v2 (wir betreiben ihn auf einem Hetzner Cloud VPS; jeder container-fähige Host funktioniert)
- Ein Anthropic Pro- oder Max-Abonnement (dieser Leitfaden verwendet OAuth-Abo-Auth, keine API-Key-Abrechnung)
- Einen bei
@BotFatherregistrierten Telegram-Bot mit dem entsprechenden Token - Die Telegram-User-IDs aller Personen, die du zum Assistenten zulassen möchtest (über
@userinfobot) - Einen Browser auf deinem Laptop für den einmaligen OAuth-Flow
- Eine klare Vorstellung des Beratungsmandats, für das diese Instanz gedacht ist
Was dabei entsteht
- Ein einzelner Docker-Container, der einen Python-Telegram-Bot und das Claude Code CLI in einer Runtime kombiniert
- OAuth gegen ein Claude Max-Abonnement, mit über Container-Neustarts hinweg persistierten Credentials
- Eine Allow-List-Middleware, die Nachrichten von Personen außerhalb der Liste lautlos verwirft – sowohl in DMs als auch in Gruppen
- Einen persistenten Workspace, in dem der Assistent im Laufe der Zeit seine eigene
CLAUDE.mdundnotes/-Dateien pflegt - Datei-Eingang für Telegram-Anhänge (PDFs, Fotos, Sprachnachrichten usw.) nach
/workspace/incoming/ - Gesprächsprotokolle in Markdown, menschenlesbar, über das Read-Tool für den Assistenten zugänglich
- Eine Identitätsschicht (
HERMES_PROJECT_NAME,HERMES_INSTANCE_ID), die den Stack durch zwei Umgebungsvariablen auf andere Mandate klonierbar macht
Das Projektinstanzmodell: Warum Per-Mandat besser ist als organisationsweit
Der Impuls beim Bau eines internen KI-Tools ist meist, es organisationsweit zu machen: ein Bot, ein Workspace, jedes Teammitglied hat Zugang. Das funktioniert für risikoarme Anwendungsfälle (ein Slack-Bot, der Dokumente zusammenfasst, ein internes Q&A-Tool). Bei der Beratungsarbeit bricht es schnell zusammen, weil jedes Mandat seine eigene Vertraulichkeitsgrenze, seine eigenen Stakeholder, seine eigene Wissensbasis und seine eigenen Fristen hat.
Das Projektinstanzmodell kehrt das um. Ein Bot pro Mandat. Ein Workspace pro Mandat. Eine Allow-List pro Mandat. Das Gedächtnis des Assistenten ist auf das Projekt beschränkt, nicht auf den Betreiber. Wenn das Mandat endet, kann die Instanz archiviert oder gelöscht werden, ohne irgendetwas anderes zu berühren.
Konkret heißt das:
- Der Telegram-Bot hat einen projektspezifischen Nutzernamen (z.B.
@some_project_assistant_bot), der separat bei BotFather registriert wird - Der Docker-Container hat einen projektspezifischen Namen (z.B.
some-project-assistant), der aus einem projektspezifischen Verzeichnis (/opt/some-project-assistant/) heraus läuft - Die OAuth-Session ist auf diese Instanz beschränkt – idealerweise ein eigenes Anthropic-Konto, wenn du mehrere Instanzen betreibst, um Refresh-Token-Rotationskonflikte zu vermeiden
- Der Workspace unter
/workspace/CLAUDE.mdenthält nur das Briefing für dieses spezifische Mandat - Die Allow-List enthält nur die Teilnehmer dieses spezifischen Mandats
Zwei Umgebungsvariablen machen den Stack als Template klonierbar: HERMES_PROJECT_NAME (der Anzeigename, der im System-Prompt und der /help-Ausgabe verwendet wird) und HERMES_INSTANCE_ID (der Slug, der in Verzeichnispfaden und dem Claude-Session-Identifier verwendet wird). Um den Stack für einen neuen Kunden zu klonen, änderst du zwei Umgebungsvariablen, registrierst einen neuen BotFather-Bot, führst ein frisches OAuth-Login durch, füllst das Workspace-Template aus – der gesamte Code bleibt bit-identisch.
Sieben Architekturentscheidungen vor der ersten Codezeile
Der Grund, warum dieser Stack klein und berechenbar ist, liegt in sieben bewussten Entscheidungen, die wir vor dem ersten Codezeile getroffen haben. Jede Entscheidung hat Alternativen, und die Alternativen sind relevant. Wenn dein Kontext sich von unserem unterscheidet, kann es sein, dass ein anderer Ast bei einer dieser Entscheidungen zu einem anderen – und möglicherweise besserem – Stack führt. Wir listen die Entscheidungen, die betrachteten Alternativen und die Abwägungen auf, die unsere Wahl bestimmt haben.
Entscheidung 1: Memory-Granularität
Die Wahl liegt zwischen einem globalen Assistenten-Memory (eine Claude-Session für alle Chats, der Assistent erinnert sich an alles über DMs und Gruppen hinweg) und einem Chat-spezifischen Memory (jeder Chat hat seine eigene isolierte Session mit strikten Privatsphäre-Grenzen zwischen DMs und Gruppenkonversationen).
Wir haben uns für global entschieden. Die Überlegung: Ein Beratungsassistent profitiert davon, Informationen über Gespräche hinweg verknüpfen zu können. Was in einer DM über eine Lieferantenbewertung besprochen wurde, fließt in die Gruppenkonversation über den Vertrag dieses Lieferanten ein. Chat-spezifisches Memory würde den Betreiber zwingen, Kontext zu wiederholen, und der Assistent würde abgekoppelt wirken.
Der Preis ist real: Es gibt keine Privatsphäre-Grenze zwischen DMs und Gruppen. Alles, was in einer DM erwähnt wird, ist potenziell in einer Gruppenantwort abrufbar. Das ist eine explizite, dokumentierte Entscheidung – kein Nebeneffekt. Für einen anderen Anwendungsfall (z.B. einen HR-Bot, bei dem persönliche Offenbarungen privat bleiben müssen) wäre Chat-spezifisches Memory die richtige Antwort.
Umsetzung: Das --continue-Flag des Claude Code CLI mit einem festen Arbeitsverzeichnis. Die Session-Datei liegt unter ~/.claude/projects/-workspace/sessions/<auto-id>.jsonl, wird per Bind-Mount persistiert und wird bei jedem nachfolgenden claude-Aufruf aus demselben Arbeitsverzeichnis fortgesetzt.
Entscheidung 2: Abo-OAuth vs. API-Key
Das Claude Code CLI lässt sich auf zwei Arten betreiben: mit dem Pro/Max-Abonnement des Betreibers (OAuth-basiert, keine verbrauchsabhängige Abrechnung) oder mit einem Anthropic-API-Key (Pay-per-Token). Der Standard ist das Abonnement. Die Falle ist, dass mehrere Umgebungsvariablen stillschweigend auf API-Key-Abrechnung umschalten, wenn sie in der übergeordneten Umgebung gesetzt sind.
Laut Anthropics Authentifizierungs-Dokumentation ist die Auflösungsreihenfolge: zuerst Bedrock/Vertex/Foundry-Cloud-Provider-Flags, dann ANTHROPIC_AUTH_TOKEN, dann ANTHROPIC_API_KEY, dann apiKeyHelper, dann CLAUDE_CODE_OAUTH_TOKEN, und schließlich das Abo-OAuth von /login. Wenn eine der höherpriorisierten Optionen gesetzt ist – auch nur als leerer String in manchen Shells – fällt das CLI nicht auf Abo-Auth durch.
Um ausschließlich OAuth-Betrieb zu garantieren, setzt du alle sechs Umgebungsvariablen explizit auf leere Strings im environment:-Block der Compose-Datei. Das ist defensiv, aber günstig.
environment:
ANTHROPIC_API_KEY: ""
ANTHROPIC_AUTH_TOKEN: ""
ANTHROPIC_BASE_URL: ""
CLAUDE_CODE_USE_BEDROCK: ""
CLAUDE_CODE_USE_VERTEX: ""
CLAUDE_CODE_USE_FOUNDRY: ""
Die Abwägung: Abo-Auth hat eine Single-Use-Refresh-Token-Rotation, die zu Konflikten führt, wenn dasselbe Anthropic-Konto gleichzeitig vom Container und vom Laptop des Betreibers genutzt wird. Bei paralleler Nutzung kommt es zu zufälligen Abmeldungen. Für einen dedizierten Assistenten ist ein dediziertes Anthropic-Konto die sauberere Lösung. Für Vergleichskontext zu verschiedenen KI-Tools und ihren Auth-Modellen, siehe unseren ehrlichen Vergleich von Claude Code, Cursor und anderen CLI-Agenten.
Entscheidung 3: Ein Container oder zwei
Der Bot braucht das Telegram-Framework (Python, aiogram). Die Claude-Engine braucht Node und das @anthropic-ai/claude-code-CLI. Man kann beides als zwei Container betreiben (z.B. Bot im Python-Container, claude im Node-Container, IPC dazwischen) oder beides in einen Container zusammenführen.
Der Zwei-Container-Ansatz ist strukturell sauberer, bringt aber ein IPC-Problem mit sich. Der Bot muss claude als subprocess aufrufen, was entweder Docker-Socket-Zugriff auf den anderen Container erfordert (ein Privilege-Escalation-Risiko) oder eine eigene dateibasierte IPC-Schicht (zusätzliche Latenz und Code). Beides ist wenig attraktiv.
Der Ein-Container-Ansatz tauscht Container-Reinheit gegen operationelle Einfachheit. Ein Image, eine OAuth-Session, ein Satz Umgebungsvariablen, ein Bind-Mount-Layout. Das Image ist ~700 MB statt ~120 MB, aber Festplattenspeicher ist selten der Engpass.
Wir haben uns für einen Container entschieden. Das Dockerfile installiert sowohl den Python-Stack (Slim-Base + pip) als auch den Node-Stack (NodeSource-Keyring + claude-code) nacheinander, definiert einen einzigen Entrypoint, und der Bot ruft claude über asyncio.create_subprocess_exec auf. Kein IPC, kein Socket-Proxy, kein Container-übergreifendes Networking.
Entscheidung 4: Workspace-Bootstrap
Der Assistent braucht eine Wissensbasis. Die Frage ist, ob man ihn mit Projektkontext vorbelegt (damit der Betreiber nicht jeden Fakt per Chat eingeben muss) oder leer startet (der Assistent lernt rein aus Interaktionen).
Wir belegen vor. Ein Workspace-Template unter templates/workspace-CLAUDE.md.template enthält Platzhalterabschnitte für: das Profil des Betreibers, die Teilnehmer und ihre Rollen, den Hintergrund des Mandats, Sprachkonventionen und Anweisungen, wie der Assistent Notizen im Laufe der Zeit pflegen soll. Wenn eine neue Instanz gestartet wird, wird das Template nach data/workspace/CLAUDE.md kopiert und die Platzhalter werden ausgefüllt.
Der Assistent pflegt die Datei dann selbst über die Write- und Edit-Tools. Wenn du ihn korrigierst („das ist nicht, wie dieser Kunde den Begriff verwendet“), kann er die Workspace-Datei aktualisieren, damit die Korrektur für zukünftige Sessions bestehen bleibt. In Kombination mit dem globalen Session-Memory gibt das dem Assistenten zwei Zustandsebenen: kurzfristig in der Claude-Session, langfristig in Workspace-Dateien. Beide werden über Container-Neustarts hinweg per Bind-Mounts persistiert.
Entscheidung 5: Gruppenverhalten und Privacy Mode
Telegram-Bots in Gruppen haben eine Privacy-Mode-Einstellung: Standardmäßig sieht ein Bot nur Nachrichten, die direkt an ihn gerichtet sind (Befehle, @-Erwähnungen, Antworten). Andere Gruppennachrichten werden dem Bot gar nicht zugestellt. Du kannst das in BotFather deaktivieren (/setprivacy → Disable), woraufhin der Bot jede Nachricht in jeder Gruppe sieht, der er angehört.
Für einen Beratungsassistenten, der aus Gruppenunterhaltungen lernen soll, ist „deaktiviert“ die richtige Einstellung. Das wirft aber eine Folgefrage auf: Wie entscheidet der Bot, auf welche Nachrichten er antwortet und welche er nur protokolliert?
Unser Trigger-Modell: In einer DM bekommt jede Nachricht eine Antwort. In einer Gruppe reagiert der Bot nur auf (a) explizite @-Erwähnungen des Bots, (b) Antworten auf seine eigenen Nachrichten, oder (c) Nachrichten, die das Wort „Hermes“ als eigenständiges Wort enthalten (Groß-/Kleinschreibung egal, mit Wortgrenzen-Matching). Jede andere Nachricht wird in /workspace/conversations/chat-<id>.md protokolliert, löst aber keinen Claude-Aufruf aus.
Der Assistent hat damit Zugriff auf den Gruppenkontext (er kann die Protokolldatei über das Read-Tool nachschlagen, wenn er Hintergrundinfos braucht), erzeugt aber keinen Lärm. Das Gesprächsprotokoll ist auch ein nützliches Artefakt für Menschen – der Betreiber kann es mit cat lesen, um die Projekthistorie zu überblicken.
Entscheidung 6: Response-UX
Claude-Antworten können 5–30 Sekunden dauern, wenn Tool-Nutzung im Spiel ist (Web-Fetches, Datei-Reads, mehrstufiges Reasoning). Die Optionen sind: gepuffert (auf die vollständige Antwort warten, eine Nachricht senden) oder gestreamt (eine einzelne Nachricht fortlaufend bearbeiten, während Tokens eintreffen).
Streaming ist ansprechender, aber komplexer: Telegrams Bearbeitungsrate pro Nachricht ist gedrosselt, aber die genaue Obergrenze ist nicht als einzelne Zahl dokumentiert. Die allgemeine Senderate (Telegrams veröffentlichte Limits: etwa 30 Nachrichten pro Sekunde global, nicht mehr als eine pro Sekunde pro Chat, 20 pro Minute pro Gruppe) gibt eine ungefähre Obergrenze. Ein naiver Token-für-Token-Edit-Stream wird schnell gedrosselt. Die Implementierung erfordert Chunk-Aggregation, Partial-Message-Handling aus Claudes Stream-JSON-Output und graceful Degradation bei gedrosselten Edits.
Wir haben uns für gepuffert entschieden. Während die Antwort generiert wird, sendet der Bot alle 5 Sekunden eine typing-Chat-Action, sodass der Nutzer in seinem Telegram-Client „schreibt...“ sieht. Wenn die Antwort fertig ist, wird sie als eine oder mehrere Nachrichten gesendet. Antworten, die 4.000 Zeichen überschreiten, werden automatisch an der letzten Absatzgrenze vor dem Limit aufgeteilt, mit einer Pause von 0,3 Sekunden zwischen Nachrichten, um innerhalb von Telegrams Senderate zu bleiben.
Entscheidung 7: Strikte Allow-List als äußere Grenze
Ein Telegram-Bot ist für jeden erreichbar, der seinen Nutzernamen kennt. Ohne Filterung werden zufällige Nutzer den Bot entdecken und Befehle ausprobieren. Für einen Beratungsassistenten ist das inakzeptabel: Der Assistent hat die OAuth-Credentials des Betreibers, hat Zugriff auf den Workspace, kann Tool-Aufrufe machen. Fremde gehören nicht in diese Schleife.
Die Allow-List ist als aiogram Outer-Middleware auf Update-Ebene implementiert – dem höchsten Abfangpunkt, vor der Filter-Auflösung und dem Handler-Lookup. Die Prüfung erfolgt auf event_from_user.id (die numerische Telegram-User-ID, die pro Nutzer stabil bleibt, auch wenn er seinen Nutzernamen ändert). Allow-List-Mitglieder werden über eine CSV-Umgebungsvariable konfiguriert (HERMES_ALLOWED_USERS). Wenn ein Absender nicht in der Liste ist, gibt die Middleware None zurück, ohne den Handler aufzurufen: kein Log-Eintrag außer einem Debug-Level-Drop-Event, kein Gesprächsprotokoll-Schreibvorgang, kein Claude-Aufruf, keine Antwort.
Das ist auch der richtige Ort für die Validierung, dass der Betreiber in der Allow-List enthalten sein muss: Der Settings-Loader (mit pydantic-settings) überprüft beim Start, dass HERMES_OPERATOR_ID in HERMES_ALLOWED_USERS enthalten ist. Fehlkonfigurationen bringen den Container sofort zum Absturz, anstatt den Betreiber stillschweigend auszusperren.
Der Container-Stack
Mit den sieben Entscheidungen ergibt sich der Stack von selbst. Hier ist die vollständige docker-compose.yml:
services:
hermes:
build: ./hermes
container_name: project-assistant
restart: unless-stopped
init: true
stop_grace_period: 15s
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
pids_limit: 512
env_file:
- .env
environment:
ANTHROPIC_API_KEY: ""
ANTHROPIC_AUTH_TOKEN: ""
ANTHROPIC_BASE_URL: ""
CLAUDE_CODE_USE_BEDROCK: ""
CLAUDE_CODE_USE_VERTEX: ""
CLAUDE_CODE_USE_FOUNDRY: ""
DISABLE_AUTOUPDATER: "1"
PYTHONDONTWRITEBYTECODE: "1"
PYTHONUNBUFFERED: "1"
TERM: xterm-256color
volumes:
- ./data/claude:/home/hermes/.claude
- ./data/claude.json:/home/hermes/.claude.json
- ./data/bot:/data
- ./data/workspace:/workspace
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
Wichtige Entscheidungen, die sich nicht von selbst erschließen:
init: true— startet tini als PID 1, damit der Python-Prozess SIGTERM sauber empfängt. Ohne das wartetdocker compose stop10 Sekunden und sendet dann SIGKILL, was die Bot-Session unkorrekt schließt.stop_grace_period: 15s— etwas länger als aiograms Standard-polling_timeoutvon 10 Sekunden. Das gibt dem Shutdown-Hook Zeit, die Telegram-Session sauber zu schließen, wasTelegramConflictError: terminated by other getUpdates requestverhindert, wenn der Container schneller neustartet, als Telegram die vorherige Long-Poll-Verbindung freigibt.cap_drop: ALLundno-new-privileges:true— der Container braucht keine Linux-Capabilities und keine Privilege-Eskalation. Beides ist standardmäßig eingeschränkt.- Kein
read_only: true— Claude Code schreibt in~/.claude/,~/.npm/und gelegentlich/tmp/für Self-Updates. Ein Read-only-Root würde umfangreiche tmpfs-Mounts erfordern, um das auszugleichen. Nicht der Aufwand für den Sicherheitsgewinn. - Die zwei Datei-Mounts —
./data/claude.json:/home/hermes/.claude.jsonmountet eine spezifische Datei (kein Verzeichnis). Diese Datei muss auf dem Host vorcompose upexistieren, initialisiert mit{}, sonst wirft Claude Code beim Start einen JSON-Parse-Fehler.
Das Dockerfile kombiniert die zwei Runtimes:
FROM python:3.13-slim-trixie
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
DEBIAN_FRONTEND=noninteractive
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl file git gnupg locales poppler-utils tmux \
&& locale-gen en_US.UTF-8 \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
RUN mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
| gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" \
> /etc/apt/sources.list.d/nodesource.list \
&& apt-get update && apt-get install -y --no-install-recommends nodejs \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
RUN npm install -g @anthropic-ai/claude-code
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN userdel -r ubuntu 2>/dev/null || true \
&& useradd -m -u 1000 -s /bin/bash hermes \
&& mkdir -p /data /workspace \
&& chown -R hermes:hermes /data /workspace /app
COPY --chown=hermes:hermes src/ ./src/
USER hermes
WORKDIR /app
CMD ["python", "-u", "-m", "src.main"]
Die gepinnten Versionen, die du kennen solltest:
python:3.13-slim-trixie— Debian 13-Basis, aktuell stabilaiogram>=3.28,<4.0— das Bot-Framework (wir pinnen auf 3.28.x zum Zeitpunkt dieses Artikels, aktuell neueste Version ist 3.28.2)pydantic-settings>=2.14,<3.0— Settings-Loaderstructlog>=25.4,<26.0— JSON-Logging@anthropic-ai/[email protected]— via npm global aus NodeSource Node 20 installiertpoppler-utils— für pdftotext als Read-Tool-Fallback, wenn Claudes PDF-Parsing nicht zur Datei passt
Zwei Dinge sind in diesem Dockerfile besonders zu beachten:
- Die
userdel -r ubuntu-Zeile. Die Python-Slim-Basis auf Debian 13 liefert keinen Standard-UID-1000-User mit, aber wenn du auf ein Basis-Image wechselst, das das tut (manche Ubuntu-Derivate), schlägtuseradd -u 1000fehl. Entferne immer zuerst den vorhandenen UID-1000-User. - Das NodeSource-Keyring-Muster. Wir vermeiden das
curl | bash-Installationsskript; es ist veraltet und nicht reproduzierbar. Der Keyring +nodistro-Codename-Ansatz ist reproduzierbar und audit-freundlich.
Für einen tieferen Einblick, wie Container-Hardening in einen Multi-Service-Produktionsstack passt, siehe unsere Anleitung zum Betrieb von über hundert Docker-Containern in Produktion.
Der Python-Stack: Fünf Module, die die Arbeit erledigen
Der Bot-Code ist in überschaubare Module aufgeteilt. Keines davon ist groß; das größte hat etwa 200 Zeilen.
settings.py— pydantic-settingsBaseSettingsmit einem eigenenBeforeValidatorfür die kommagetrennte Allow-List. Der Validator behandelt den Sonderfall, bei dem pydantic-settings einen nackten Integer JSON-dekodiert (eine einelementige Allow-List wieHERMES_ALLOWED_USERS=12345wird zuint, nicht zulist[int]) und konvertiert ihn in eine einelementige Liste.middleware.py—AllowListMiddlewarealsdp.update.outer_middleware. Die Prüfung aufdata.get("event_from_user")nutzt aiograms eingebaute User-Kontext-Extraktion.trigger.py—is_trigger(message, bot_id, bot_username). Gibt(True, "DM" | "@mention" | "text_mention" | "reply" | "keyword")oder(False, None)zurück. Das Keyword-Matching verwendet einen Wortgrenzen-Regex (\bhermes\b), damit Teilstrings keinen Trigger auslösen.conversation_log.py— Append-only Markdown-Protokolle nach/workspace/conversations/chat-<id>.md. Sowohl eingehende als auch ausgehende Nachrichten werden protokolliert.file_intake.py— verarbeitet acht Telegram-Anhangtypen (document, photo, video, audio, voice, animation, sticker, video_note). Lädt nach/workspace/incoming/chat-<id>/<timestamp>-<name>herunter, mit einem Hard-Limit von 20 MB (Telegrams Bot-API-Download-Cap).hermes_engine.py— kapselt den Claude Code CLI-subprocess. Verwendetasyncio.create_subprocess_execmitcwd=/workspaceund--continue(nach dem ersten Aufruf), um die globale Session aufrechtzuerhalten.response_pipeline.py— kombiniert Typing-Indicator-Refresh, Auto-Split und Inter-Message-Delay.handlers.py— drei Befehls-Handler (/ping,/status,/help) und ein Default-Message-Handler, der die Trigger-Prüfung ausführt und an die Engine weiterleitet.
Der Claude CLI-Aufruf ist das zentrale Stück. Hier ist der vollständige Subprocess-Aufruf:
cmd = [
"claude",
"--print",
"--add-dir", "/workspace",
"--dangerously-skip-permissions",
"--append-system-prompt", _build_system_prompt(),
]
if SESSION_MARKER.exists():
cmd.append("--continue")
cmd.append(_build_user_prompt(text, ctx))
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd="/workspace",
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=300)
SESSION_MARKER.touch(exist_ok=True)
return stdout.decode("utf-8").strip()
Der System-Prompt legt die Persona des Assistenten fest, die Validierungsdisziplin (nichts behaupten, erst über Tools verifizieren), die Lernschleife (neue Fakten in /workspace/CLAUDE.md oder notes/ schreiben), das Telegram-Ausgabeformat (Klartext, kein Markdown – Telegrams Standardmodus rendert Markdown nicht zuverlässig) und einen Hinweis, dass die Gesprächsprotokolle bei Bedarf lesbar sind.
Der User-Prompt verpackt die eigentliche Nachricht mit Kontext-Metadaten: Chat-Quelle (DM mit X oder Gruppe Y mit Mitgliedern A, B, C), Absenderidentität, Zeitstempel, Trigger-Grund und den Dateipfad des Gesprächsprotokolls für diesen Chat. Das ermöglicht es dem Assistenten, vor der Antwort zu entscheiden, ob er den Gruppenkontext nachschlagen soll.
Schritt für Schritt: Eine neue Instanz starten
Angenommen, die Codebasis liegt in einem Git-Repo und du hast einen Server mit installiertem Docker – hier ist der vollständige Bootstrap. Ersetze deine eigenen Werte für <instance-id> und <project-name>.
Schritt 1: Bot bei BotFather registrieren.
- In Telegram,
@BotFatheranschreiben /newbot→ Name und Nutzernamen festlegen (z.B.some_project_assistant_bot)- Den Token speichern, den BotFather zurückgibt
/setprivacy→ deinen Bot auswählen → Disable (damit der Bot alle Gruppennachrichten sieht, nicht nur Befehle und Erwähnungen)- Optional:
/setcommandsmitping,status,help
Schritt 2: Repo klonen und Verzeichnisse auf dem Server vorbereiten.
sudo mkdir -p /opt/<instance-id>
sudo chown $USER /opt/<instance-id>
git clone <repo-url> /opt/<instance-id>
cd /opt/<instance-id>
mkdir -p data/claude data/bot data/workspace/{notes,conversations,incoming}
touch data/claude.json
sudo chown -R 1000:1000 data/
Der chown-Schritt ist entscheidend und leicht zu vergessen. Docker erstellt fehlende Bind-Mount-Quellverzeichnisse als Root-owned; wenn du chown überspringst, kann der Container-User (UID 1000) nicht in sie schreiben und das OAuth-Login schlägt stillschweigend fehl. Überprüfe mit stat -c "%a %u:%g" data/claude — du willst 1000:1000, nicht 0:0.
Schritt 3: Workspace initialisieren.
cp templates/workspace-CLAUDE.md.template data/workspace/CLAUDE.md
# Datei öffnen und Platzhalter ersetzen:
# {{HERMES_PROJECT_NAME}}, {{OPERATOR_NAME}}, {{CLIENT_LEGAL_ENTITY}}, etc.
Schritt 4: Secrets konfigurieren.
cp .env.example .env
chmod 600 .env
sudo chown 1000:1000 .env
# .env bearbeiten: TELEGRAM_BOT_TOKEN, HERMES_ALLOWED_USERS,
# HERMES_OPERATOR_ID, HERMES_PROJECT_NAME, HERMES_INSTANCE_ID
Schritt 5: Vorbedingung – /start an den Bot senden.
Telegram erlaubt es einem Bot nicht, einem Nutzer eine DM zu schicken, bis der Nutzer den Chat mindestens einmal initiiert hat. Vor dem ersten Container-Start muss der Betreiber den Bot in Telegram öffnen und /start senden. Der Bot antwortet nicht (kein Handler für /start ist registriert), aber Telegram öffnet intern den DM-Kanal. Ohne diesen Schritt wirft die erste Onboarding-DM TelegramForbiddenError.
Schritt 6: Container bauen und starten.
docker compose build hermes
docker compose up -d hermes
sleep 5
docker compose logs --tail=20 hermes
Du solltest eine JSON-Log-Sequenz sehen: startup (mit Projektname und Anzahl der allowed_users), polling_initialized (mit drop_pending=true), onboarding_sent (die erste DM an den Betreiber), dann aiograms Polling-Started-Events.
Schritt 7: OAuth-Login.
Das ist ein einmaliger, interaktiver Schritt. In einem Terminal, das breit genug ist, um die OAuth-URL in einer Zeile anzuzeigen (250+ Zeichen – sonst bricht die URL um und Cloudflare lehnt die Auth mit Unknown scope: us ab):
docker exec -it project-assistant tmux new-session -s claude
# Im Container:
claude
# Im claude REPL:
/login
# Auswahl: "Claude Pro or Max subscription"
# Die angezeigte URL kopieren, im Browser öffnen, einloggen, den
# Autorisierungscode zurück ins Terminal einfügen.
/status # sollte "Subscription" anzeigen, nicht "API key"
/quit
# tmux mit Ctrl-b d ablösen, dann docker exec verlassen.
Schritt 8: Rauchtest in Telegram.
- Bot anschreiben:
/ping→pong - Bot anschreiben:
/status→ Diagnose-Output (Allow-List-Anzahl, Session-Nachrichten, Log-Anzahl) - Bot anschreiben: eine natürliche Frage – der Assistent sollte mit der Claude-Engine antworten
- Ein kleines PDF oder Bild mit Bildunterschrift senden – der Assistent sollte auf den Inhalt eingehen
- Zwei Nachrichten hintereinander senden, wobei die zweite sich auf die erste bezieht – das Multi-Turn-Memory sollte funktionieren
Schritt 9: Weitere Teilnehmer hinzufügen (wenn bereit).
- Jeder Teilnehmer ruft seine Telegram-User-ID über
@userinfobotab .envaktualisieren:HERMES_ALLOWED_USERS=<operator_id>,<member1_id>,<member2_id>docker compose restart hermes- Eine Telegram-Gruppe erstellen, alle Mitglieder und den Bot hinzufügen, mit
/ping@some_project_assistant_bottesten
Betriebshinweise und bekannte Risiken
Der Stack läuft stabil, aber einige Punkte sind es wert, bekannt zu sein.
Cloudflare WAF bei OAuth-Refresh. Anthropics Auth-Endpoint sitzt hinter Cloudflare. Es gibt ein bekanntes Problem (offen im anthropics/claude-code-Repo), bei dem Cloudflare bestimmte Server-IPs als headless Linux klassifiziert und OAuth-Refresh dauerhaft blockiert. Betroffene berichten von wochenlangen Aussperrungen. Der Weg zurück führt über die erneute Authentifizierung von einer anderen IP (Wohnleitung, VPN). Die Minderungsmaßnahme besteht darin, eigene User-Agents und aggressive Retry-Schleifen zu vermeiden, und claude setup-token (ein Jahrestoken, der von einem authentifizierten Gerät generiert und über CLAUDE_CODE_OAUTH_TOKEN in den Container eingespeist wird) als Fallback in Betracht zu ziehen, wenn du in einem Hochrisiko-IP-Bereich betreibst.
Single-Use-Rotation bei Refresh-Tokens. Anthropics OAuth-Refresh-Tokens sind Single-Use. Wenn zwei Clients (z.B. dein Laptop und dein Container) dasselbe Konto nutzen und beide parallel refreshen, macht das erste Refresh die andere Seite ungültig. Die praktische Empfehlung: ein dediziertes Anthropic-Konto pro Assistenz-Instanz. Wenn das nicht möglich ist, muss man mit gelegentlichen Neu-Logins leben und Claude Code nicht gleichzeitig auf dem Laptop und im Assistenten betreiben.
Die leere-claude.json-Falle. Wenn data/claude.json beim ersten Container-Start eine Nullbyte-Datei ist, wirft Claude Code den Fehler Configuration Error: invalid JSON, Unexpected EOF. Initialisiere sie mit echo "{}" > data/claude.json, nicht mit touch. Der Fehler ist im REPL behebbar („Reset with default configuration“), aber besser, die Reibung zu vermeiden.
Der OAuth-URL-Zeilenumbruch-Bug (unsere Beobachtung). Die OAuth-URL ist ungefähr 530 Zeichen lang. In einem Terminal normaler Breite bricht sie über mehrere Zeilen um. Wenn du die umgebrochene Ausgabe kopierst, werden die Zeilenumbrüche mitgenommen, und nach URL-Encoding sieht der Scope-Parameter aus wie user:inference us\ner:profile. Cloudflare sieht dann us als Scope und lehnt mit Invalid OAuth Request — Unknown scope: us ab. Das ist zum Zeitpunkt dieses Artikels kein dokumentiertes upstream-Problem – wir sind selbst darauf gestoßen. Das Terminal auf 250+ Zeichen zu verbreitern, bevor man claude startet, vermeidet das Problem, oder man kann die URL nach dem Einfügen in der Adressleiste des Browsers manuell zusammenführen.
Der Ubuntu-UID-1000-Konflikt. Manche modernen Ubuntu-Basis-Images – insbesondere ubuntu:24.04 (Noble) nach dem Canonical-OCI-Rebase – liefern einen Standard-ubuntu-User mit UID 1000 mit. Wenn du die Dockerfile-Basis von python:3.13-slim-trixie (das keinen Standard-UID-1000-User mitliefert) auf eine wechselst, die das tut, schlägt useradd -u 1000 hermes mit UID 1000 is not unique fehl. Das Dockerfile enthält aus diesem Grund ein defensives userdel -r ubuntu 2>/dev/null || true vor useradd.
Das Gesprächsprotokoll kann wachsen. Die Append-only-Protokolle in /workspace/conversations/ wachsen mit jeder Nachricht. Bei monatelanger aktiver Nutzung können einzelne Dateien Megabyte-Größe erreichen. Eine eingebaute Rotation gibt es nicht. Wer das stört, richtet einen Cron-ähnlichen Job ein, um Protokolle älter als N Tage zu archivieren, oder teilt pro Monat auf.
Für übergreifende Betriebsfragen zu selbst gehosteten Diensten und was passiert, wenn sie ausfallen, siehe unseren Artikel zur Disaster Recovery für selbst gehostete Dienste.
Was bewusst außerhalb des Scope liegt
Beim Bau eines internen KI-Tools ist die Versuchung groß, Features hinzuzufügen. Wir halten diesen Stack klein. Folgendes liegt für die hier beschriebene Version explizit außerhalb des Scope, und wir haben aktiv entschieden, es noch nicht hinzuzufügen:
- RAG / Vektordatenbank. Das Wissen des Assistenten steckt in Markdown-Dateien (
CLAUDE.mdundnotes/) und Gesprächsprotokollen. Read-Tool-Aufrufe übernehmen das Retrieval. Das reicht für einen Single-Mandat-Scope. Sobald der Workspace eine bestimmte Größe überschreitet, wird eine echte RAG-Schicht (z.B. PostgreSQL + pgvector) nötig, aber bis dahin ist es Overkill. - Audio-Transkription. Sprachnachrichten werden heruntergeladen und Metadaten protokolliert, aber der Assistent kann sie noch nicht transkribieren. Whisper oder eine ähnliche Pipeline hinzuzufügen ist ein halber Tag Arbeit, aufgeschoben bis es gebraucht wird.
- Health-Check-Endpoint. Der Container hat keinen HTTP-Server; es gibt nichts zu scrapen. Dockers Restart-Policy plus Log-Monitoring deckt die meisten Ausfallmodi ab.
- Streaming-Antworten. Siehe Entscheidung 6 oben.
- Multi-Tenant in einem einzelnen Container. Jedes Mandat bekommt seinen eigenen Container. Das ist beabsichtigt – siehe das Projektinstanzmodell oben.
Die aufgeschobenen Features sind keine Fehler. Sie sind Entscheidungen, und sie reduzieren die Angriffsfläche für das, was tatsächlich vorhanden ist. Für Kontext zur Disziplin, KI-Tools eng zu halten, siehe KI-Agent-Skills für domänenspezifische Workflows bauen und die Claude Skills Übersicht.
Klonen für das nächste Mandat
Das Zwei-Env-Var-Design zahlt sich hier aus. Um eine neue Instanz aufzusetzen:
- Repo nach
/opt/<new-instance-id>klonen HERMES_PROJECT_NAMEundHERMES_INSTANCE_IDin.envändern- Neuen Bot bei BotFather registrieren (einmalig)
- Workspace-Template mit dem Kontext des neuen Mandats ausfüllen (einmalig)
- OAuth-Login aus dem neuen Container heraus durchführen (einmalig, idealerweise auf einem separaten Anthropic-Konto)
docker compose up -d
Der Code ist bit-identisch über alle Instanzen hinweg. Was sich unterscheidet, sind nur die zwei Env-Vars, die Bot-Credentials, der Workspace-Inhalt und die OAuth-Session.
Du kannst mehrere Instanzen auf demselben Server betreiben. Jede hat ihr eigenes Verzeichnis, ihren eigenen Container-Namen, ihren eigenen Bind-Mount-Baum. Der Festplattenverbrauch beträgt grob 700 MB Image (dank Dockers Layer-Cache zwischen Instanzen geteilt) plus Workspace-Wachstum pro Instanz (typischerweise einige Dutzend MB nach Monaten).
Wo das in die Toolchain passt
Ein projektspezifischer KI-Assistent ersetzt keine allgemeinen KI-Coding-Tools. Wir nutzen Claude Code, Cursor und Gemini CLI weiterhin direkt für die Entwicklungsarbeit. Der Assistent ist für den Beratungskontext gedacht: Projektgedächtnis, Dokumentenanalyse, Statusupdates, Ad-hoc-Recherche innerhalb eines definierten Mandats. Er läuft parallel zum restlichen KI-Tool-Stack, nicht anstelle davon.
Das Muster ersetzt auch keine chat-basierten KI-Produkte wie Claude.ai oder ChatGPT. Die sind die richtige Antwort für einmalige Aufgaben, persönliche Fragen und allgemeine Wissensarbeit. Ein projektspezifischer Assistent ist die richtige Antwort, wenn das Projekt seine eigene Grenze hat, seine eigenen Teilnehmer und seine eigene wachsende Wissensbasis, die man nicht bei jedem Bezug in einen generischen Chatbot eingeben möchte.
Wenn du überlegst, etwas Ähnliches für deine Beratungspraxis, Agentur oder dein internes Team zu bauen, ist der obige Stack ein vernünftiger Ausgangspunkt. Das Dockerfile, die Compose-Datei und die Modulstruktur lassen sich aus diesem Leitfaden reproduzieren. Die Entscheidungen sind dokumentiert. Wenn dein Kontext bei einer der sieben Entscheidungen von unserem abweicht, verzweige und passe an – der Code ist kurz genug, dass das Umschreiben eines Moduls unkompliziert ist. Meld dich, wenn du Erfahrungen austauschen oder Hilfe bei einer spezifischen Implementierung möchtest.