Construire un assistant IA dédié à un projet via Telegram
Un assistant IA limité à un seul mandat client est structurellement différent d'un outil IA à l'échelle d'une organisation. Les périmètres diffèrent, le modèle mémoire diffère, les secrets diffèrent, le cycle de déploiement diffère. Ce guide décrit le modèle d'assistant IA par projet que nous utilisons chez tva : un bot Telegram par mandat de conseil, chacun adossé à sa propre instance du CLI Claude Code avec un OAuth persistant, son propre espace de travail, et sa propre liste blanche de participants. Le modèle est clonable d'un mandat à l'autre, et l'ensemble de la stack tourne dans un seul conteneur Docker.
Ce guide est rédigé pour qu'un développeur (ou un LLM) puisse le lire de bout en bout et reproduire la configuration from scratch. Toutes les versions sont épinglées. Tous les choix de configuration sont justifiés. Chaque décision d'architecture liste les alternatives écartées et les raisons du choix.
Ce dont vous aurez besoin
- Un serveur Linux avec Docker Engine 29.x et Docker Compose v2 (nous tournons sur un VPS Hetzner Cloud ; n'importe quel hôte capable de faire tourner des conteneurs convient)
- Un abonnement Anthropic Pro ou Max (ce guide utilise l'authentification OAuth par abonnement, pas la facturation par clé API)
- Un bot Telegram enregistré via
@BotFatheravec son token en poche - Les identifiants Telegram numériques de chaque personne que vous souhaitez autoriser à accéder à l'assistant (via
@userinfobot) - Un navigateur sur votre machine pour le flux OAuth unique
- Une vision claire du mandat de conseil que cette instance va servir
Ce que vous obtiendrez
- Un seul conteneur Docker combinant un bot Telegram Python et le CLI Claude Code dans un même runtime
- Une authentification OAuth sur un abonnement Claude Max, avec les credentials persistés entre les redémarrages du conteneur
- Un middleware de liste blanche qui rejette silencieusement les messages de toute personne absente de la liste, aussi bien en DM qu'en groupe
- Un espace de travail persistant où l'assistant maintient ses propres fichiers
CLAUDE.mdetnotes/au fil du temps - L'ingestion des pièces jointes Telegram (PDFs, photos, notes vocales, etc.) dans
/workspace/incoming/ - Des journaux de conversation en Markdown, lisibles par un humain, accessibles à l'assistant via le Read-Tool
- Une couche d'identité (
HERMES_PROJECT_NAME,HERMES_INSTANCE_ID) qui rend la stack clonable vers d'autres mandats avec seulement deux changements de variables d'environnement
Le modèle d'instance par projet : pourquoi le par-mandat bat l'organisationnel
Le réflexe naturel quand on construit un outil IA interne est de le rendre accessible à toute l'organisation : un seul bot, un seul espace de travail, tous les membres de l'équipe ont accès. Ça fonctionne pour les cas d'usage à faibles enjeux (un bot Slack qui résume des documents, un outil de Q&A interne). Ça se déglingue rapidement pour le conseil, où chaque mandat a son propre périmètre de confidentialité, ses propres parties prenantes, sa propre base de connaissances, et ses propres délais.
Le modèle d'instance par projet inverse cette logique. Un bot par mandat. Un espace de travail par mandat. Une liste blanche par mandat. La mémoire de l'assistant est délimitée au projet, pas à l'opérateur. Quand le mandat se termine, l'instance peut être archivée ou détruite sans toucher à quoi que ce soit d'autre.
Concrètement :
- Le bot Telegram a un nom d'utilisateur spécifique au projet (ex.
@some_project_assistant_bot), enregistré séparément auprès de BotFather - Le conteneur Docker a un nom spécifique au projet (ex.
some-project-assistant), tournant depuis un répertoire dédié (/opt/some-project-assistant/) - La session OAuth est scoped à cette instance — idéalement un compte Anthropic distinct si vous faites tourner plusieurs instances, pour éviter les conflits de rotation de refresh-token
- L'espace de travail sur
/workspace/CLAUDE.mdne contient que le brief de ce mandat spécifique - La liste blanche ne contient que les participants de ce mandat spécifique
Deux variables d'environnement rendent la stack clonable comme un template : HERMES_PROJECT_NAME (le nom affiché, utilisé dans le prompt système et la sortie de /help) et HERMES_INSTANCE_ID (le slug utilisé dans les chemins de répertoires et l'identifiant de session Claude). Pour cloner la stack pour un nouveau client, on change deux variables d'env, on enregistre un nouveau bot BotFather, on fait un nouveau login OAuth, on renseigne le template d'espace de travail, et l'intégralité du code reste bit-identique.
Sept décisions d'architecture à prendre avant d'écrire la moindre ligne
Si cette stack est petite et prévisible, c'est parce que nous avons pris sept décisions délibérées avant d'écrire la première ligne de code. Chaque décision a ses alternatives, et ces alternatives comptent. Si votre contexte diffère du nôtre, prendre l'autre embranchement sur n'importe lequel de ces points vous donne une stack différente — et potentiellement meilleure. Nous listons les décisions, les alternatives envisagées, et les compromis qui ont guidé notre choix.
Décision 1 : Granularité de la mémoire
Le choix se pose entre une mémoire globale de l'assistant (une seule session Claude pour toutes les conversations, l'assistant mémorise tout à travers DMs et groupes) et une mémoire par conversation (chaque chat a sa propre session isolée, avec des frontières de confidentialité strictes entre DMs et conversations de groupe).
Nous avons choisi le global. La logique : un assistant de conseil tire profit de sa capacité à relier des informations issues de différentes conversations. Ce qui a été discuté en DM à propos d'une évaluation de fournisseur nourrit la conversation de groupe sur le contrat de ce même fournisseur. La mémoire par conversation forcerait l'opérateur à répéter le contexte en permanence, et l'assistant semblerait déconnecté.
Le coût est réel : il n'y a pas de frontière de confidentialité entre DMs et groupes. Tout ce qui est mentionné en DM est potentiellement rappelable dans une réponse de groupe. C'est un choix explicite et documenté — pas un effet de bord. Pour un cas d'usage différent (ex. un bot RH où les confidences personnelles doivent rester privées), la mémoire par conversation serait la bonne réponse.
Implémentation : le flag --continue du CLI Claude Code avec un répertoire de travail fixe. Le fichier de session réside dans ~/.claude/projects/-workspace/sessions/<auto-id>.jsonl, persisté via bind-mount, et reprend à chaque invocation ultérieure de claude depuis le même répertoire de travail.
Décision 2 : OAuth abonnement ou clé API
On peut piloter le CLI Claude Code de deux façons : avec l'abonnement Pro/Max de l'opérateur (OAuth, pas de facturation par appel) ou avec une clé API Anthropic (facturation par token). Le mode par défaut est l'abonnement. Le piège, c'est que plusieurs variables d'environnement basculent silencieusement vers la facturation par clé API si elles sont définies dans l'environnement parent.
D'après la documentation d'authentification d'Anthropic, l'ordre de résolution est : les flags de cloud provider Bedrock/Vertex/Foundry en premier, puis ANTHROPIC_AUTH_TOKEN, puis ANTHROPIC_API_KEY, puis apiKeyHelper, puis CLAUDE_CODE_OAUTH_TOKEN, et enfin l'OAuth abonnement via /login. Si l'une des options de précédence supérieure est définie — même à une chaîne vide dans certains shells — le CLI ne tombera pas en retrait sur l'auth par abonnement.
Pour garantir un fonctionnement exclusivement OAuth, définissez les six variables d'environnement explicitement à des chaînes vides dans le bloc environment: du fichier compose. C'est défensif, mais peu coûteux.
environment:
ANTHROPIC_API_KEY: ""
ANTHROPIC_AUTH_TOKEN: ""
ANTHROPIC_BASE_URL: ""
CLAUDE_CODE_USE_BEDROCK: ""
CLAUDE_CODE_USE_VERTEX: ""
CLAUDE_CODE_USE_FOUNDRY: ""
Le compromis : l'auth par abonnement utilise une rotation de refresh-token à usage unique qui crée des conflits si le même compte Anthropic est utilisé à la fois par le conteneur et par le laptop de l'opérateur simultanément. En cas d'utilisation parallèle, vous aurez des déconnexions aléatoires. Pour un assistant dédié, un compte Anthropic dédié est la solution la plus propre. Pour comparer différents outils IA et leurs modèles d'authentification, voir notre comparaison honnête de Claude Code, Cursor et autres agents CLI.
Décision 3 : Un conteneur ou deux
Le bot a besoin du framework Telegram (Python, aiogram). Le moteur Claude a besoin de Node et du CLI @anthropic-ai/claude-code. On peut les faire tourner dans deux conteneurs séparés (ex. bot dans un conteneur Python, claude dans un conteneur Node, IPC entre les deux) ou les fusionner en un seul.
L'approche à deux conteneurs est structurellement plus propre, mais introduit un problème d'IPC. Le bot doit invoquer claude en subprocess, ce qui implique soit un accès au socket Docker de l'autre conteneur (risque d'escalade de privilèges), soit une couche IPC basée sur des fichiers (latence supplémentaire et code en plus). Ni l'un ni l'autre n'est attrayant.
L'approche à conteneur unique échange la pureté des conteneurs contre la simplicité opérationnelle. Une seule image, une seule session OAuth, un seul jeu de variables d'environnement, une seule organisation de bind-mounts. L'image fait environ 700 Mo au lieu de 120 Mo, mais le disque est rarement le goulot d'étranglement.
Nous avons choisi le conteneur unique. Le Dockerfile installe à la fois la stack Python (base slim + pip) et la stack Node (keyring NodeSource + claude-code) en séquence, expose un seul entrypoint, et le bot appelle claude via asyncio.create_subprocess_exec. Pas d'IPC, pas de proxy socket, pas de réseau inter-conteneurs.
Décision 4 : Amorçage de l'espace de travail
L'assistant a besoin d'une base de connaissances. Le choix est d'y injecter le contexte du projet en amont (pour que l'opérateur n'ait pas à alimenter chaque fait via le chat) ou de démarrer vide (l'assistant apprend uniquement des interactions).
Nous injectons. Un template d'espace de travail dans templates/workspace-CLAUDE.md.template contient des sections à remplir pour : le profil de l'opérateur, les participants et leurs rôles, le contexte du mandat, les conventions linguistiques, et les instructions sur la façon dont l'assistant doit tenir ses notes au fil du temps. Quand une nouvelle instance est démarrée, le template est copié dans data/workspace/CLAUDE.md et les placeholders sont renseignés.
L'assistant maintient ensuite le fichier lui-même via les outils Write et Edit. Quand vous le corrigez (« ce client n'emploie pas ce terme comme ça »), il peut mettre à jour le fichier de l'espace de travail pour que la correction persiste dans les sessions futures. Combiné avec la mémoire de session globale, cela donne à l'assistant deux couches d'état : court terme dans la session Claude, long terme dans les fichiers de l'espace de travail. Les deux persistent entre les redémarrages du conteneur via les bind-mounts.
Décision 5 : Comportement en groupe et mode de confidentialité
Les bots Telegram en groupe disposent d'un paramètre de mode de confidentialité : par défaut, un bot ne voit que les messages qui lui sont directement adressés (commandes, @mentions, réponses). Les autres messages du groupe ne lui parviennent pas du tout. Vous pouvez désactiver cela dans BotFather (/setprivacy → Disable), auquel cas le bot voit tous les messages dans chaque groupe dont il est membre.
Pour un assistant de conseil qui doit apprendre des discussions de groupe, le mode désactivé est le bon choix. Mais cela soulève une question : comment le bot décide-t-il des messages auxquels répondre et de ceux à simplement journaliser ?
Notre modèle de déclenchement : en DM, chaque message obtient une réponse. En groupe, le bot ne répond qu'à (a) les @mentions explicites du bot, (b) les réponses à ses propres messages précédents, ou (c) les messages contenant le mot « Hermes » en tant que mot indépendant (insensible à la casse, correspondance sur les frontières de mots). Tous les autres messages sont journalisés dans /workspace/conversations/chat-<id>.md mais ne déclenchent pas d'appel à Claude.
Ainsi, l'assistant a accès en lecture au contexte du groupe (il peut consulter le fichier journal via le Read-Tool quand il a besoin d'un arrière-plan) sans générer du bruit. Le journal de conversation est aussi un artefact humain utile — l'opérateur peut faire un cat dessus pour voir l'historique du projet.
Décision 6 : UX des réponses
Les réponses de Claude peuvent prendre 5 à 30 secondes quand des outils sont sollicités (récupération web, lectures de fichiers, raisonnement en plusieurs étapes). Les options sont : bufférisé (attendre la réponse complète, envoyer un message) ou streamé (modifier un seul message progressivement au fur et à mesure que les tokens arrivent).
Le streaming est plus impressionnant, mais plus complexe : le taux de modification de messages de Telegram est limité, mais le plafond exact n'est pas documenté comme un seul chiffre. Le débit d'envoi global (les limites publiées par Telegram : environ 30 messages par seconde globalement, pas plus d'un par seconde par chat, 20 par minute par groupe) donne une borne supérieure approximative. Un stream token par token naïf se fait throttler rapidement. L'implémentation requiert une agrégation par chunks, la gestion des messages partiels depuis la sortie stream-json de Claude, et une dégradation gracieuse quand les modifications sont throttlées.
Nous avons choisi le mode bufférisé. Pendant que la réponse est générée, le bot envoie une action de chat typing toutes les 5 secondes, afin que l'utilisateur voie « est en train d'écrire » dans son client Telegram. Quand la réponse est prête, elle part en un ou plusieurs messages. Les réponses de plus de 4 000 caractères sont automatiquement découpées à la dernière limite de paragraphe avant le seuil, avec une pause de 0,3 seconde entre les messages pour rester dans les limites de débit de Telegram.
Décision 7 : La liste blanche stricte comme frontière externe
Un bot Telegram est accessible à quiconque trouve son nom d'utilisateur. Sans filtrage, des utilisateurs aléatoires découvriront le bot et tenteront des commandes. Pour un assistant de conseil, c'est inacceptable : l'assistant détient les credentials OAuth de l'opérateur, a accès à l'espace de travail, peut effectuer des appels d'outils. Vous ne voulez pas d'inconnus dans cette boucle.
La liste blanche est implémentée comme un outer-middleware aiogram au niveau de l'update — le point d'interception le plus élevé, avant la résolution des filtres et la recherche de handler. La vérification porte sur event_from_user.id (l'identifiant Telegram numérique de l'utilisateur, qui est stable même quand il change de nom d'utilisateur). Les membres de la liste blanche sont configurés via une variable d'environnement CSV (HERMES_ALLOWED_USERS). Si l'expéditeur n'est pas dans l'ensemble, le middleware retourne None sans invoquer le handler : pas d'entrée de log au-delà d'un événement de rejet au niveau debug, pas d'écriture dans le journal de conversation, pas d'appel à Claude, pas de réponse.
C'est aussi le bon endroit pour la validation « l'opérateur doit être dans la liste » : le chargeur de settings (avec pydantic-settings) vérifie que HERMES_OPERATOR_ID est bien contenu dans HERMES_ALLOWED_USERS au démarrage. Les mauvaises configurations font planter le conteneur immédiatement plutôt que de bloquer silencieusement l'accès à l'opérateur.
La stack conteneur
Les sept décisions prises, la stack s'en déduit naturellement. Voici le docker-compose.yml complet :
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"
Quelques choix qui ne sautent pas aux yeux à la lecture :
init: true— lance tini comme PID 1, afin que le processus Python reçoive SIGTERM proprement. Sans ça,docker compose stopattend 10 secondes puis envoie un SIGKILL, laissant la session bot non fermée.stop_grace_period: 15s— légèrement supérieur aupolling_timeoutpar défaut d'aiogram de 10 secondes. Cela donne au hook d'arrêt le temps de fermer la session Telegram proprement, ce qui éviteTelegramConflictError: terminated by other getUpdates requestquand le conteneur redémarre plus vite que Telegram ne libère la connexion long-poll précédente.cap_drop: ALLetno-new-privileges:true— le conteneur n'a besoin d'aucune capacité Linux, aucune escalade de privilèges. Les deux sont restreints par défaut.- Pas de
read_only: true— Claude Code écrit dans~/.claude/,~/.npm/, et occasionnellement dans/tmp/pour ses mises à jour automatiques. Un système de fichiers racine en lecture seule nécessiterait de nombreux montages tmpfs compensatoires. Pas rentable pour le gain de sécurité obtenu. - Les deux montages de fichiers —
./data/claude.json:/home/hermes/.claude.jsonmonte un fichier spécifique (pas un répertoire). Ce fichier doit exister sur l'hôte avantcompose up, initialisé avec{}, sinon Claude Code lève une erreur de parsing JSON au démarrage.
Le Dockerfile combine les deux 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"]
Les versions épinglées à connaître :
python:3.13-slim-trixie— base Debian 13, stable actuelleaiogram>=3.28,<4.0— le framework bot (nous épinglons à 3.28.x au moment de la rédaction, la dernière version est 3.28.2)pydantic-settings>=2.14,<3.0— chargeur de settingsstructlog>=25.4,<26.0— logs JSON@anthropic-ai/[email protected]— installé via npm global depuis NodeSource Node 20poppler-utils— pour pdftotext comme solution de repli du Read-Tool quand le parsing PDF natif de Claude ne convient pas au fichier
Deux points d'attention dans ce Dockerfile :
- La ligne
userdel -r ubuntu. La base Python slim sur Debian 13 ne livre pas d'utilisateur UID 1000 par défaut, mais si vous passez à une image de base qui en a un (certains dérivés Ubuntu), leuseradd -u 1000échouera. Supprimez toujours l'utilisateur UID 1000 existant en premier. - Le pattern du keyring NodeSource. Nous évitons le script d'installation
curl | bash; il est déprécié et non reproductible. L'approche keyring + codenamenodistroest reproductible et facile à auditer.
Pour une vue plus approfondie de la façon dont le durcissement des conteneurs s'intègre dans une stack de production multi-services, voir notre guide sur la gestion de plus d'une centaine de conteneurs Docker en production.
La stack Python : cinq modules qui font le travail
Le code du bot est découpé en modules ciblés. Aucun n'est volumineux ; le plus grand tourne autour de 200 lignes.
settings.py—BaseSettingsde pydantic-settings avec unBeforeValidatorpersonnalisé pour la liste blanche séparée par des virgules. Le validateur gère le cas limite où pydantic-settings décode JSON un entier brut (une liste blanche à un seul élément commeHERMES_ALLOWED_USERS=12345devientint, paslist[int]) et le convertit en liste à un seul élément.middleware.py—AllowListMiddlewarecommedp.update.outer_middleware. La vérification surdata.get("event_from_user")utilise l'extraction de contexte utilisateur intégrée d'aiogram.trigger.py—is_trigger(message, bot_id, bot_username). Retourne(True, "DM" | "@mention" | "text_mention" | "reply" | "keyword")ou(False, None). La correspondance par mot-clé utilise une regex sur les frontières de mots (\bhermes\b) pour éviter que des sous-chaînes déclenchent une réponse.conversation_log.py— journaux Markdown en mode ajout seul vers/workspace/conversations/chat-<id>.md. Les messages entrants et sortants sont tous journalisés.file_intake.py— gère huit types de pièces jointes Telegram (document, photo, vidéo, audio, voice, animation, sticker, video_note). Télécharge vers/workspace/incoming/chat-<id>/<timestamp>-<name>avec une limite stricte à 20 Mo (plafond de téléchargement de l'API bot Telegram).hermes_engine.py— enveloppe le subprocess du CLI Claude Code. Utiliseasyncio.create_subprocess_execaveccwd=/workspaceet--continue(après le premier appel) pour maintenir la session globale.response_pipeline.py— combine le rafraîchissement de l'indicateur de frappe, le découpage automatique, et le délai entre les messages.handlers.py— trois handlers de commandes (/ping,/status,/help) et un handler de message par défaut qui exécute la vérification du déclencheur et dispatche vers le moteur.
L'invocation du CLI Claude est la pièce centrale. Voici l'appel subprocess complet :
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()
Le prompt système définit la personnalité de l'assistant, la discipline de validation (ne pas affirmer, valider d'abord via les outils), la boucle d'apprentissage (écrire les nouveaux faits dans /workspace/CLAUDE.md ou notes/), le format de sortie Telegram (texte brut, pas de Markdown — le mode par défaut de Telegram ne rend pas le Markdown de façon fiable), et une note sur la disponibilité des journaux de conversation en lecture à la demande.
Le prompt utilisateur encapsule le message réel avec des métadonnées de contexte : source du chat (DM avec X, ou groupe Y avec les membres A, B, C), identité de l'expéditeur, horodatage, raison du déclenchement, et le chemin du fichier du journal de conversation pour ce chat. Cela permet à l'assistant de décider s'il doit consulter le contexte du groupe avant de répondre.
Étape par étape : démarrer une nouvelle instance
En supposant que le code est dans un dépôt Git et que vous disposez d'un serveur avec Docker installé, voici le bootstrap complet. Substituez vos propres valeurs pour <instance-id> et <project-name>.
Étape 1 : Enregistrer le bot avec BotFather.
- Dans Telegram, messagez
@BotFather /newbot→ définir le nom et le nom d'utilisateur (ex.some_project_assistant_bot)- Sauvegarder le token renvoyé par BotFather
/setprivacy→ sélectionner votre bot → Disable (pour que le bot voie tous les messages du groupe, pas seulement les commandes et mentions)- Optionnel :
/setcommandsavecping,status,help
Étape 2 : Cloner le dépôt et préparer les répertoires sur le serveur.
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/
L'étape chown est critique et facile à oublier. Docker crée les répertoires sources de bind-mount manquants en appartenance root ; si vous sautez le chown, l'utilisateur du conteneur (UID 1000) ne peut pas y écrire et le login OAuth échoue silencieusement. Vérifiez avec stat -c "%a %u:%g" data/claude — vous voulez 1000:1000, pas 0:0.
Étape 3 : Initialiser l'espace de travail.
cp templates/workspace-CLAUDE.md.template data/workspace/CLAUDE.md
# Ouvrir le fichier et remplacer les placeholders :
# {{HERMES_PROJECT_NAME}}, {{OPERATOR_NAME}}, {{CLIENT_LEGAL_ENTITY}}, etc.
Étape 4 : Configurer les secrets.
cp .env.example .env
chmod 600 .env
sudo chown 1000:1000 .env
# Éditer .env : TELEGRAM_BOT_TOKEN, HERMES_ALLOWED_USERS,
# HERMES_OPERATOR_ID, HERMES_PROJECT_NAME, HERMES_INSTANCE_ID
Étape 5 : Prérequis — envoyer /start au bot.
Telegram ne laisse pas un bot envoyer un DM à un utilisateur tant que celui-ci n'a pas initié la conversation au moins une fois. Avant le premier démarrage du conteneur, l'opérateur doit ouvrir le bot dans Telegram et envoyer /start. Le bot ne répondra pas (aucun handler n'est enregistré pour /start), mais Telegram ouvre le canal DM en interne. Sans cette étape, le premier DM d'onboarding lève un TelegramForbiddenError.
Étape 6 : Construire et démarrer le conteneur.
docker compose build hermes
docker compose up -d hermes
sleep 5
docker compose logs --tail=20 hermes
Vous devriez voir une séquence de logs JSON : startup (avec le nom du projet et le nombre d'utilisateurs autorisés), polling_initialized (avec drop_pending=true), onboarding_sent (le premier DM à l'opérateur), puis les événements de démarrage du polling d'aiogram.
Étape 7 : Login OAuth.
C'est une étape unique et interactive. Dans un terminal suffisamment large pour afficher l'URL OAuth sur une seule ligne (250 caractères ou plus — sinon l'URL se coupe et Cloudflare rejette l'auth avec Unknown scope: us) :
docker exec -it project-assistant tmux new-session -s claude
# Dans le conteneur :
claude
# Dans le REPL claude :
/login
# Choisir : "Claude Pro or Max subscription"
# Copier l'URL affichée, l'ouvrir dans un navigateur, se connecter, coller le
# code d'autorisation dans le terminal.
/status # doit afficher "Subscription", pas "API key"
/quit
# Détacher tmux avec Ctrl-b d, puis quitter le docker exec.
Étape 8 : Test de fumée dans Telegram.
- Envoyer en DM au bot :
/ping→pong - Envoyer en DM au bot :
/status→ sortie de diagnostic (nombre d'utilisateurs autorisés, messages de session, compteurs de logs) - Envoyer en DM au bot : une question naturelle — l'assistant devrait répondre en utilisant le moteur Claude
- Envoyer un petit PDF ou une image avec une légende — l'assistant devrait en référencer le contenu
- Envoyer deux messages à la suite, où le second fait référence au premier — la mémoire multi-tours devrait tenir
Étape 9 : Ajouter des participants supplémentaires (quand vous êtes prêt).
- Chaque participant récupère son identifiant Telegram depuis
@userinfobot - Mettre à jour
.env:HERMES_ALLOWED_USERS=<operator_id>,<member1_id>,<member2_id> docker compose restart hermes- Créer un groupe Telegram, y inviter tous les membres et le bot, tester avec
/ping@some_project_assistant_bot
Notes opérationnelles et risques connus
La stack tourne de façon stable, mais plusieurs points méritent attention.
WAF Cloudflare sur le refresh OAuth. Le point de terminaison d'auth d'Anthropic passe par Cloudflare. Il existe un problème connu (ouvert dans le dépôt anthropics/claude-code) où Cloudflare classe certaines IP de serveurs comme des machines Linux headless et bloque définitivement le refresh OAuth. Des utilisateurs ont signalé des blocages durant des semaines. La procédure de récupération consiste à se ré-authentifier depuis une IP différente (résidentielle, VPN). La mitigation est d'éviter les user-agents personnalisés et les boucles de retry agressives, et d'envisager claude setup-token (un token d'un an généré depuis une machine authentifiée, injecté dans le conteneur via CLAUDE_CODE_OAUTH_TOKEN) comme solution de repli si vous opérez dans une plage d'IP à risque élevé.
Rotation de refresh-token à usage unique. Les refresh-tokens OAuth d'Anthropic sont à usage unique. Si deux clients (ex. votre laptop et votre conteneur) partagent le même compte et rafraîchissent en parallèle, le premier refresh invalide l'autre côté. Le conseil pratique : un compte Anthropic dédié par instance d'assistant. Si vous ne pouvez pas, acceptez les re-logins occasionnels et n'utilisez pas Claude Code sur votre laptop et dans l'assistant simultanément.
Le piège du claude.json vide. Si data/claude.json est un fichier de zéro octet au premier démarrage du conteneur, Claude Code lève Configuration Error: invalid JSON, Unexpected EOF. Initialisez-le avec echo "{}" > data/claude.json, pas touch. L'erreur est récupérable dans le REPL (« Reset with default configuration »), mais autant éviter la friction.
Le bug de retour à la ligne de l'URL OAuth (notre observation). L'URL OAuth fait environ 530 caractères. Dans un terminal de largeur normale, elle se coupe sur plusieurs lignes. Quand vous copiez la sortie découpée, les sauts de ligne viennent avec, et après encodage URL, le paramètre scope ressemble à user:inference us\ner:profile. Cloudflare voit alors us comme un scope et rejette avec Invalid OAuth Request — Unknown scope: us. Ce n'est pas suivi comme un problème upstream au moment de la rédaction — nous l'avons rencontré nous-mêmes. Élargir le terminal à 250 caractères ou plus avant de lancer claude l'évite, ou vous pouvez supprimer manuellement les retours à la ligne dans la barre d'adresse de votre navigateur après avoir collé l'URL.
Le conflit UID 1000 avec Ubuntu. Certaines images de base Ubuntu modernes — notamment ubuntu:24.04 (Noble) après le rebase OCI de Canonical — embarquent un utilisateur ubuntu par défaut à l'UID 1000. Si vous passez la base Dockerfile de python:3.13-slim-trixie (qui ne livre pas d'utilisateur UID 1000 par défaut) à une qui en a un, useradd -u 1000 hermes échoue avec UID 1000 is not unique. Le Dockerfile inclut un userdel -r ubuntu 2>/dev/null || true défensif avant useradd pour cette raison.
Le journal de conversation peut grossir. Les journaux en mode ajout seul dans /workspace/conversations/ grandissent à chaque message. Après des mois d'utilisation active, des fichiers individuels peuvent atteindre plusieurs mégaoctets. Il n'y a pas de rotation intégrée. Si cela vous préoccupe, ajoutez une tâche de type cron pour archiver les journaux plus anciens que N jours, ou découpez par mois.
Pour des questions opérationnelles plus larges autour des services auto-hébergés et ce qui se passe quand ils tombent, voir notre article sur la reprise après sinistre pour les services auto-hébergés.
Ce qui est délibérément hors périmètre
La tentation quand on construit un outil IA interne est d'ajouter des fonctionnalités. Nous gardons cette stack petite. Les éléments suivants sont explicitement hors périmètre pour la version décrite ici, et nous avons fait un choix actif de ne pas les ajouter pour l'instant :
- RAG / base de données vectorielle. La connaissance de l'assistant réside dans des fichiers Markdown (
CLAUDE.mdetnotes/) et les journaux de conversation. Les appels au Read-Tool gèrent la récupération. C'est suffisant pour un périmètre à mandat unique. Une fois que l'espace de travail dépasse une certaine taille, une vraie couche RAG (PostgreSQL + pgvector, par exemple) devient nécessaire, mais jusque-là c'est du surdimensionnement. - Transcription audio. Les notes vocales sont téléchargées et les métadonnées sont journalisées, mais l'assistant ne peut pas encore les transcrire. Ajouter Whisper ou un pipeline similaire représente une demi-journée de travail, reportée jusqu'à ce que le besoin se présente.
- Endpoint de health check. Le conteneur n'a pas de serveur HTTP ; il n'y a rien à scraper. La restart-policy de Docker combinée à la surveillance des logs couvre la plupart des modes de défaillance.
- Réponses en streaming. Voir la Décision 6 ci-dessus.
- Multi-tenant sur un seul conteneur. Chaque mandat a son propre conteneur. C'est intentionnel — voir le modèle d'instance par projet ci-dessus.
Les fonctionnalités reportées ne sont pas des bugs. Ce sont des choix, et ils réduisent la surface des choses qui existent. Pour du contexte sur la discipline de laisser les outils IA rester ciblés, voir construire des compétences d'agents IA pour des workflows métier spécifiques et l'aperçu des Claude Skills.
Cloner pour le prochain mandat
La conception à deux variables d'environnement porte ses fruits ici. Pour déployer une nouvelle instance :
- Cloner le dépôt vers
/opt/<new-instance-id> - Changer
HERMES_PROJECT_NAMEetHERMES_INSTANCE_IDdans.env - Enregistrer un nouveau bot avec BotFather (unique)
- Renseigner le template d'espace de travail avec le contexte du nouveau mandat (unique)
- Login OAuth depuis l'intérieur du nouveau conteneur (unique, idéalement sur un compte Anthropic séparé)
docker compose up -d
Le code est bit-identique entre les instances. Les seules choses qui varient sont les deux variables d'env, les credentials du bot, le contenu de l'espace de travail, et la session OAuth.
Vous pouvez faire tourner plusieurs instances sur le même serveur. Chacune a son propre répertoire, son propre nom de conteneur, son propre arbre de bind-mounts. L'utilisation disque est d'environ 700 Mo d'image (partagée entre les instances grâce au cache de couches Docker) plus la croissance de l'espace de travail par instance (généralement de l'ordre de la dizaine de Mo après des mois d'utilisation).
La place de cet outil dans la chaîne
Un assistant IA dédié à un projet ne remplace pas les outils IA polyvalents de développement. Nous utilisons toujours Claude Code, Cursor et Gemini CLI directement pour le travail de développement. L'assistant est là pour le contexte du conseil : mémoire de projet, analyse de documents, points de situation, recherche ad hoc dans le cadre d'un mandat défini. Il tourne en parallèle du reste de la stack d'outils IA, pas à la place de celle-ci.
Ce modèle ne remplace pas non plus les produits IA conversationnels comme Claude.ai ou ChatGPT. Ceux-ci sont la bonne réponse pour les tâches ponctuelles, les questions personnelles et le travail de connaissance générale. Un assistant dédié à un projet est la bonne réponse quand le projet a son propre périmètre, ses propres participants, et sa propre base de connaissances évolutive que vous ne voulez pas reverser dans un chatbot générique à chaque fois que vous avez besoin de vous y référer.
Si vous envisagez de construire quelque chose de similaire pour votre cabinet de conseil, votre agence, ou votre équipe interne, la stack ci-dessus est un point de départ raisonnable. Le Dockerfile, le fichier compose et la structure des modules sont reproductibles à partir de ce guide. Les décisions sont documentées. Si votre contexte diffère du nôtre sur l'une des sept décisions, bifurquez et adaptez — le code est suffisamment court pour que réécrire un module soit une opération simple. Contactez-nous si vous souhaitez comparer vos approches ou que nous vous aidions sur une implémentation spécifique.