tva
← Insights

Costruire un assistente AI dedicato a un progetto via Telegram

Un assistente AI circoscritto a un singolo mandato di consulenza è strutturalmente diverso da uno strumento AI a livello organizzativo. Confini diversi, modello di memoria diverso, segreti diversi, ciclo di vita del deployment diverso. Questa guida descrive il pattern dell'assistente AI specifico per progetto che usiamo in tva: un bot Telegram per ogni mandato di consulenza, ciascuno supportato dalla propria istanza di Claude Code CLI con OAuth persistente, il proprio workspace e la propria lista di partecipanti autorizzati. Il pattern è clonabile tra i mandati come template, e l'intero stack gira in un singolo container Docker.

Questa guida è scritta in modo che uno sviluppatore (o un LLM) possa leggerla in sequenza e riprodurre la configurazione da zero. Ogni versione è fissata. Ogni scelta di configurazione è motivata. Ogni decisione architetturale elenca le alternative che abbiamo scartato e perché.

Cosa ti servirà

  • Un server Linux con Docker Engine 29.x e Docker Compose v2 (noi giriamo su un VPS Hetzner Cloud; va bene qualsiasi host che supporti container)
  • Un abbonamento Anthropic Pro o Max (questa guida usa l'autenticazione OAuth via subscription, non la fatturazione per API key)
  • Un bot Telegram registrato tramite @BotFather con il relativo token a portata di mano
  • Gli ID utente Telegram di ogni persona che si vuole ammettere all'assistente (tramite @userinfobot)
  • Un browser sul proprio laptop per il flusso OAuth da effettuare una sola volta
  • Un'idea chiara del mandato di consulenza che questa istanza dovrà servire

Cosa costruisce

  • Un singolo container Docker che combina un bot Telegram in Python e Claude Code CLI in un unico runtime
  • OAuth contro un abbonamento Claude Max, con le credenziali persistite attraverso i riavvii del container
  • Un middleware con lista di autorizzati che scarta silenziosamente i messaggi di chi non è in lista, sia nei DM che nei gruppi
  • Un workspace persistente in cui l'assistente mantiene nel tempo i propri file CLAUDE.md e notes/
  • Acquisizione di file per gli allegati Telegram (PDF, foto, messaggi vocali, ecc.) in /workspace/incoming/
  • Log delle conversazioni in Markdown, leggibili da un essere umano, accessibili all'assistente tramite Read-Tool
  • Un livello di identità (HERMES_PROJECT_NAME, HERMES_INSTANCE_ID) che rende lo stack clonabile per altri mandati con la modifica di soli due variabili d'ambiente

Il modello a istanza di progetto: perché per-mandato batte org-wide

L'istinto predefinito quando si costruisce uno strumento AI interno è renderlo a livello organizzativo: un bot, un workspace, accesso per tutti i membri del team. Funziona per casi d'uso a basso rischio (un bot Slack che riassume documenti, uno strumento interno di Q&A). Si inceppa rapidamente nel lavoro di consulenza, dove ogni mandato ha i propri confini di riservatezza, i propri stakeholder, la propria base di conoscenza e le proprie scadenze.

Il modello a istanza di progetto ribalta questa logica. Un bot per mandato. Un workspace per mandato. Una lista di autorizzati per mandato. La memoria dell'assistente è circoscritta al progetto, non all'operatore. Quando il mandato si chiude, l'istanza può essere archiviata o distrutta senza toccare nient'altro.

In concreto:

  • Il bot Telegram ha un username specifico per il progetto (es. @some_project_assistant_bot) registrato separatamente con BotFather
  • Il container Docker ha un nome specifico per il progetto (es. some-project-assistant), in esecuzione da una directory specifica (/opt/some-project-assistant/)
  • La sessione OAuth è circoscritta a questa istanza — idealmente un account Anthropic separato se si gestiscono più istanze, per evitare conflitti di rotazione del refresh token
  • Il workspace in /workspace/CLAUDE.md contiene solo il briefing per questo specifico mandato
  • La lista di autorizzati contiene solo i partecipanti a questo specifico mandato

Due variabili d'ambiente rendono lo stack clonabile come template: HERMES_PROJECT_NAME (il nome visualizzato, usato nel system prompt e nell'output di /help) e HERMES_INSTANCE_ID (lo slug usato nei percorsi delle directory e nell'identificatore di sessione di Claude). Per clonare lo stack per un nuovo cliente, si cambiano due variabili d'ambiente, si registra un nuovo bot con BotFather, si effettua un nuovo login OAuth, si compila il template del workspace, e l'intero codebase rimane bit-identico.

Sette decisioni architetturali da prendere prima di scrivere codice

Se questo stack è piccolo e prevedibile è perché abbiamo preso sette decisioni deliberate prima di scrivere la prima riga di codice. Ogni decisione ha alternative, e le alternative contano. Se il tuo contesto è diverso dal nostro, scegliere l'altro ramo su qualsiasi di queste ti dà uno stack diverso (e probabilmente migliore). Elenchiamo le decisioni, le alternative che abbiamo considerato e i compromessi che hanno guidato la nostra scelta.

Decisione 1: Granularità della memoria

La scelta è tra una memoria globale dell'assistente (una sola sessione Claude per tutte le chat, l'assistente ricorda tutto tra DM e gruppi) e una memoria per-chat (ogni chat ha la propria sessione isolata, con confini di privacy rigidi tra DM e conversazioni di gruppo).

Abbiamo scelto la memoria globale. Il ragionamento: un assistente di consulenza trae vantaggio dalla capacità di collegare informazioni tra conversazioni. Quanto discusso in un DM a proposito di una valutazione di un fornitore alimenta la conversazione di gruppo sul contratto di quel fornitore. La memoria per-chat costringerebbe l'operatore a ripetere il contesto, e l'assistente sembrerebbe disconnesso.

Il costo è reale: non esiste alcun confine di privacy tra DM e gruppi. Qualsiasi cosa menzionata in un DM è potenzialmente richiamabile in una risposta di gruppo. Si tratta di una scelta esplicita e documentata, non di un effetto collaterale. Per un caso d'uso diverso (es. un bot HR dove le rivelazioni personali devono restare private), la memoria per-chat sarebbe la risposta corretta.

Implementazione: il flag --continue di Claude Code CLI con una working directory fissa. Il file di sessione si trova in ~/.claude/projects/-workspace/sessions/<auto-id>.jsonl, persistito tramite bind-mount, e riprende a ogni successiva invocazione di claude dalla stessa working directory.

Decisione 2: OAuth via subscription vs API key

Si può pilotare Claude Code CLI in due modi: con l'abbonamento Pro/Max dell'operatore (basato su OAuth, senza fatturazione per chiamata) oppure con una API key di Anthropic (pagamento per token). Il default è l'abbonamento. La trappola è che alcune variabili d'ambiente passano silenziosamente alla fatturazione via API key se sono impostate nell'ambiente padre.

Secondo la documentazione di autenticazione di Anthropic, l'ordine di risoluzione è: flag cloud-provider Bedrock/Vertex/Foundry per primi, poi ANTHROPIC_AUTH_TOKEN, poi ANTHROPIC_API_KEY, poi apiKeyHelper, poi CLAUDE_CODE_OAUTH_TOKEN, e infine l'OAuth via subscription da /login. Se una qualsiasi delle opzioni a priorità più alta è impostata — anche a stringa vuota in alcune shell — la CLI non ricadrà sull'autenticazione via subscription.

Per garantire il funzionamento esclusivamente tramite OAuth, imposta tutte e sei le variabili d'ambiente esplicitamente a stringhe vuote nel blocco environment: del compose file. È una misura difensiva ma economica.

environment:
  ANTHROPIC_API_KEY: ""
  ANTHROPIC_AUTH_TOKEN: ""
  ANTHROPIC_BASE_URL: ""
  CLAUDE_CODE_USE_BEDROCK: ""
  CLAUDE_CODE_USE_VERTEX: ""
  CLAUDE_CODE_USE_FOUNDRY: ""

Il compromesso: l'autenticazione OAuth via subscription ha una rotazione del refresh token a uso singolo che crea conflitti se lo stesso account Anthropic viene usato sia dal container che dal laptop dell'operatore contemporaneamente. Se usi entrambi in parallelo, riceverai disconnessioni casuali. Per un assistente dedicato, un account Anthropic dedicato è la soluzione più pulita. Per un confronto con i modelli di autenticazione di altri strumenti AI, vedi il nostro confronto onesto tra Claude Code, Cursor e altri agenti CLI.

Decisione 3: Un container o due

Il bot ha bisogno del framework Telegram (Python, aiogram). Il motore Claude ha bisogno di Node e della CLI @anthropic-ai/claude-code. Si possono far girare come due container (es. bot in container Python, claude in container Node, IPC tra di loro) oppure unirli in uno.

L'approccio a due container è strutturalmente più pulito ma introduce un problema di IPC. Il bot deve invocare claude come subprocess, il che significa che ha bisogno o di accesso al Docker socket dell'altro container (un rischio di privilege escalation) o di un layer di IPC basato su file personalizzato (latenza aggiuntiva e codice extra). Nessuno dei due è appetibile.

L'approccio a container singolo sacrifica la purezza dei container in favore della semplicità operativa. Un'immagine, una sessione OAuth, un set di variabili d'ambiente, un layout di bind-mount. L'immagine pesa circa 700 MB invece di ~120 MB, ma il disco è raramente il collo di bottiglia.

Abbiamo scelto il container singolo. Il Dockerfile installa in sequenza sia lo stack Python (base slim + pip) che lo stack Node (keyring NodeSource + claude-code), espone un singolo entrypoint, e il bot chiama claude tramite asyncio.create_subprocess_exec. Nessun IPC, nessun socket proxy, nessun networking tra container.

Decisione 4: Bootstrap del workspace

L'assistente ha bisogno di una base di conoscenza. La scelta è se pre-caricarlo con il contesto del progetto (così l'operatore non deve fornire ogni fatto tramite chat) oppure partire vuoto (l'assistente impara esclusivamente dalle interazioni).

Noi pre-carichiamo. Un template del workspace in templates/workspace-CLAUDE.md.template contiene sezioni segnaposto per: il profilo dell'operatore, i partecipanti e i loro ruoli, il background del mandato, le convenzioni linguistiche e le istruzioni su come l'assistente dovrebbe mantenere le note nel tempo. Quando si fa il bootstrap di una nuova istanza, il template viene copiato in data/workspace/CLAUDE.md e i segnaposto vengono compilati.

L'assistente mantiene poi il file autonomamente tramite i tool Write ed Edit. Quando lo si corregge («il cliente non usa quel termine in questo modo»), può aggiornare il file del workspace in modo che la correzione persista per le sessioni future. Combinato con la memoria di sessione globale, questo fornisce all'assistente due livelli di stato: a breve termine nella sessione Claude, a lungo termine nei file del workspace. Entrambi persistono attraverso i riavvii del container tramite bind-mount.

Decisione 5: Comportamento nei gruppi e modalità privacy

I bot Telegram nei gruppi hanno un'impostazione di modalità privacy: per impostazione predefinita, un bot vede solo i messaggi indirizzati direttamente a lui (comandi, @menzioni, risposte). Gli altri messaggi di gruppo non vengono consegnati al bot. Puoi disabilitare questa modalità in BotFather (/setprivacy → Disable), dopodiché il bot vede ogni messaggio in ogni gruppo di cui è membro.

Per un assistente di consulenza che dovrebbe imparare dalle discussioni di gruppo, la modalità disabilitata è l'impostazione corretta. Ma questo solleva una domanda successiva: come decide il bot a quali messaggi rispondere e quali semplicemente registrare?

Il nostro modello di trigger: in un DM, ogni messaggio riceve una risposta. In un gruppo, il bot risponde solo a (a) @menzioni esplicite del bot, (b) risposte ai propri messaggi precedenti, o (c) messaggi che contengono la parola «Hermes» come parola autonoma (case-insensitive, con corrispondenza a confine di parola). Ogni altro messaggio viene registrato in /workspace/conversations/chat-<id>.md ma non avvia una chiamata a Claude.

Questo significa che l'assistente ha accesso in lettura al contesto di gruppo (può consultare il file di log tramite il tool Read quando ha bisogno di background) ma non genera rumore. Il log delle conversazioni è anche un artefatto utile per gli esseri umani — l'operatore può leggerlo con cat per vedere la storia del progetto.

Decisione 6: UX della risposta

Le risposte di Claude possono richiedere da 5 a 30 secondi quando è coinvolto l'uso di tool (fetch web, lettura file, ragionamento multi-step). Le opzioni sono: con buffer (si aspetta la risposta completa, si invia un messaggio solo) oppure in streaming (si modifica un singolo messaggio progressivamente man mano che arrivano i token).

Lo streaming è più elegante ma più complesso: il rate di modifica per messaggio di Telegram è limitato, ma il tetto esatto non è documentato come un singolo numero. Il rate di invio generale (i limiti pubblicati da Telegram: circa 30 messaggi al secondo globalmente, non più di uno al secondo per chat, 20 al minuto per gruppo) dà un limite superiore approssimativo. Uno stream token-per-token ingenuo viene rallentato rapidamente. L'implementazione richiede aggregazione a chunk, gestione di messaggi parziali dall'output stream-json di Claude, e degradazione elegante quando le modifiche vengono rallentate.

Abbiamo scelto il buffer. Mentre la risposta viene generata, il bot invia un'azione chat typing ogni 5 secondi in modo che l'utente veda «sta scrivendo» nel proprio client Telegram. Quando la risposta è pronta, viene inviata come uno o più messaggi. Le risposte più lunghe di 4.000 caratteri vengono suddivise automaticamente all'ultimo confine di paragrafo prima del limite, con una pausa di 0,3 secondi tra i messaggi per restare entro il rate di invio di Telegram.

Decisione 7: Lista di autorizzati stretta come confine esterno

Un bot Telegram è raggiungibile da chiunque ne trovi il nome utente. Se non si filtra, utenti casuali scopriranno il bot e proveranno a usare i comandi. Per un assistente di consulenza, questo è inaccettabile: l'assistente ha le credenziali OAuth dell'operatore, ha accesso al workspace, può effettuare chiamate ai tool. Non si vuole che degli estranei entrino in questo flusso.

La lista di autorizzati è implementata come un outer-middleware di aiogram a livello di update — il punto di intercettazione più alto, prima della risoluzione dei filter e della ricerca degli handler. Il controllo avviene su event_from_user.id (l'ID utente Telegram numerico, che è stabile per ogni utente anche quando cambiano il proprio username). I membri della lista di autorizzati vengono configurati tramite una variabile d'ambiente CSV (HERMES_ALLOWED_USERS). Se un mittente non è nell'insieme, il middleware restituisce None senza invocare l'handler: nessuna voce nel log al di là di un evento di scarto a livello debug, nessuna scrittura nel log delle conversazioni, nessuna chiamata a Claude, nessuna risposta.

Questo è anche il posto giusto per la validazione che l'operatore deve essere nella lista: il loader delle impostazioni (usando pydantic-settings) verifica che HERMES_OPERATOR_ID sia contenuto in HERMES_ALLOWED_USERS all'avvio. Le configurazioni errate fanno crashare il container immediatamente, invece di bloccare silenziosamente l'operatore.

Lo stack del container

Con le sette decisioni prese, lo stack ne consegue naturalmente. Ecco il docker-compose.yml completo:

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"

Scelte chiave che non sono ovvie alla lettura:

  • init: true — esegue tini come PID 1, così il processo Python riceve SIGTERM correttamente. Senza questo, docker compose stop aspetta 10 secondi e poi invia SIGKILL, lasciando la sessione del bot non chiusa.
  • stop_grace_period: 15s — leggermente più lungo del polling_timeout di default di aiogram di 10 secondi. Questo dà all'hook di shutdown il tempo di chiudere la sessione Telegram in modo pulito, il che evita TelegramConflictError: terminated by other getUpdates request quando il container si riavvia più velocemente di quanto Telegram rilasci la connessione di long-poll precedente.
  • cap_drop: ALL e no-new-privileges:true — il container non ha bisogno di capability Linux, né di escalation dei privilegi. Entrambi sono ristretto per default.
  • Nessun read_only: true — Claude Code scrive in ~/.claude/, ~/.npm/ e occasionalmente in /tmp/ per gli auto-aggiornamenti. Un filesystem root di sola lettura richiederebbe mount tmpfs estesi per compensare. Non vale il guadagno in sicurezza.
  • I due mount di file./data/claude.json:/home/hermes/.claude.json monta un file specifico (non una directory). Questo file deve esistere sull'host prima di compose up, inizializzato con {}, altrimenti Claude Code lancia un errore di parsing JSON all'avvio.

Il Dockerfile combina i due runtime:

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"]

Le versioni fissate di cui essere a conoscenza:

  • python:3.13-slim-trixie — base Debian 13, stable corrente
  • aiogram>=3.28,<4.0 — il framework del bot (blocchiamo alla 3.28.x al momento della scrittura, la più recente è la 3.28.2)
  • pydantic-settings>=2.14,<3.0 — loader delle impostazioni
  • structlog>=25.4,<26.0 — logging JSON
  • @anthropic-ai/[email protected] — installato tramite npm global da NodeSource Node 20
  • poppler-utils — per pdftotext come fallback del Read-tool quando il parsing PDF di Claude non si adatta al file

Due cose a cui prestare attenzione in questo Dockerfile:

  1. La riga userdel -r ubuntu. La base Python slim su Debian 13 non include un utente UID-1000 di default, ma se si passa a un'immagine base che ce l'ha (alcune varianti Ubuntu), il useradd -u 1000 fallirà. Rimuovi sempre prima l'utente UID-1000 esistente.
  2. Il pattern del keyring NodeSource. Evitiamo lo script di installazione curl | bash; è deprecato e non riproducibile. L'approccio con keyring e codename nodistro è riproducibile e facile da verificare.

Per una visione più approfondita di come il container hardening si inserisce in uno stack di produzione multi-servizio, vedi la nostra panoramica su come gestire oltre cento container Docker in produzione.

Lo stack Python: i cinque moduli che fanno il lavoro

Il codice del bot è suddiviso in moduli focalizzati. Nessuno di essi è grande; il più lungo è circa 200 righe.

  • settings.py — pydantic-settings BaseSettings con un BeforeValidator personalizzato per la lista di autorizzati separata da virgole. Il validator gestisce il caso limite in cui pydantic-settings decodifica in JSON un intero puro (una lista di autorizzati con un solo elemento come HERMES_ALLOWED_USERS=12345 diventa int, non list[int]) e lo converte in una lista a elemento singolo.
  • middleware.pyAllowListMiddleware come dp.update.outer_middleware. Il controllo su data.get("event_from_user") usa l'estrazione del contesto utente integrata di aiogram.
  • trigger.pyis_trigger(message, bot_id, bot_username). Restituisce (True, "DM" | "@mention" | "text_mention" | "reply" | "keyword") oppure (False, None). La corrispondenza per parola chiave usa una regex con confine di parola (\bhermes\b) in modo che le sottostringhe non triggherino.
  • conversation_log.py — log in Markdown solo in append su /workspace/conversations/chat-<id>.md. Vengono registrati sia i messaggi in entrata che quelli in uscita.
  • file_intake.py — gestisce otto tipi di allegati Telegram (document, photo, video, audio, voice, animation, sticker, video_note). Scarica in /workspace/incoming/chat-<id>/<timestamp>-<name> con un limite rigido di 20 MB (il cap di download dell'API bot di Telegram).
  • hermes_engine.py — incapsula il subprocess della CLI Claude Code. Usa asyncio.create_subprocess_exec con cwd=/workspace e --continue (dopo la prima chiamata) per mantenere la sessione globale.
  • response_pipeline.py — combina il refresh dell'indicatore di digitazione, la suddivisione automatica e il ritardo tra messaggi.
  • handlers.py — tre handler di comando (/ping, /status, /help) e un handler di messaggio predefinito che esegue il controllo del trigger e smista verso il motore.

L'invocazione della CLI Claude è il pezzo centrale. Ecco la chiamata subprocess completa:

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()

Il system prompt definisce la persona dell'assistente, la disciplina di validazione (non affermare, valida prima tramite tool), il ciclo di apprendimento (scrivi i nuovi fatti in /workspace/CLAUDE.md o notes/), il formato di output per Telegram (testo semplice, senza Markdown — la modalità default di Telegram non renderizza il Markdown in modo affidabile), e una nota sul fatto che i log delle conversazioni sono disponibili per la lettura su richiesta.

Il prompt utente racchiude il messaggio effettivo con metadati di contesto: fonte della chat (DM con X, o gruppo Y con i membri A, B, C), identità del mittente, timestamp, motivo del trigger e il percorso del file del log delle conversazioni per questa chat. Questo permette all'assistente di decidere se consultare il contesto di gruppo prima di rispondere.

Passo per passo: bootstrap di una nuova istanza

Partendo dal presupposto che il codebase sia in un repo Git e che si disponga di un server con Docker installato, ecco il bootstrap completo. Sostituisci i tuoi valori per <instance-id> e <project-name>.

Passo 1: Registra il bot con BotFather.

  • Su Telegram, scrivi a @BotFather
  • /newbot → imposta nome e username (es. some_project_assistant_bot)
  • Salva il token che BotFather restituisce
  • /setprivacy → seleziona il tuo bot → Disable (in modo che il bot veda tutti i messaggi di gruppo, non solo comandi e menzioni)
  • Facoltativo: /setcommands con ping, status, help

Passo 2: Clona il repo e prepara le directory sul server.

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/

Il passo chown è critico e facile da dimenticare. Docker crea le directory sorgente dei bind-mount mancanti come di proprietà di root; se salti il chown, l'utente del container (UID 1000) non può scrivervi e il login OAuth fallisce silenziosamente. Verifica con stat -c "%a %u:%g" data/claude — vuoi vedere 1000:1000, non 0:0.

Passo 3: Inizializza il workspace.

cp templates/workspace-CLAUDE.md.template data/workspace/CLAUDE.md
# Apri il file e sostituisci i segnaposto:
# {{HERMES_PROJECT_NAME}}, {{OPERATOR_NAME}}, {{CLIENT_LEGAL_ENTITY}}, ecc.

Passo 4: Configura i segreti.

cp .env.example .env
chmod 600 .env
sudo chown 1000:1000 .env
# Modifica .env: TELEGRAM_BOT_TOKEN, HERMES_ALLOWED_USERS,
# HERMES_OPERATOR_ID, HERMES_PROJECT_NAME, HERMES_INSTANCE_ID

Passo 5: Pre-condizione — invia /start al bot.

Telegram non consente a un bot di inviare un DM a un utente finché l'utente non ha avviato la chat almeno una volta. Prima del primo avvio del container, l'operatore deve aprire il bot su Telegram e inviare /start. Il bot non risponderà (nessun handler è registrato per /start), ma Telegram apre il canale DM internamente. Senza questo passo, il primo DM di onboarding lancia TelegramForbiddenError.

Passo 6: Builda e avvia il container.

docker compose build hermes
docker compose up -d hermes
sleep 5
docker compose logs --tail=20 hermes

Dovresti vedere una sequenza di log JSON: startup (con nome progetto e conteggio degli utenti autorizzati), polling_initialized (con drop_pending=true), onboarding_sent (il primo DM all'operatore), poi gli eventi di avvio del polling di aiogram.

Passo 7: Login OAuth.

Questo è un passo interattivo da eseguire una sola volta. In un terminale abbastanza largo da visualizzare l'URL OAuth su una sola riga (250+ caratteri — altrimenti l'URL va a capo e Cloudflare rifiuta l'autenticazione con Unknown scope: us):

docker exec -it project-assistant tmux new-session -s claude
# All'interno del container:
claude
# All'interno del REPL claude:
/login
# Scegli: "Claude Pro or Max subscription"
# Copia l'URL visualizzato, aprilo in un browser, effettua il login, incolla il
# codice di autorizzazione nel terminale.
/status   # dovrebbe mostrare "Subscription", non "API key"
/quit
# Scollega tmux con Ctrl-b d, poi esci da docker exec.

Passo 8: Smoke test su Telegram.

  • DM al bot: /pingpong
  • DM al bot: /status → output diagnostico (conteggio lista autorizzati, messaggi di sessione, conteggi log)
  • DM al bot: una domanda in linguaggio naturale — l'assistente dovrebbe rispondere usando il motore Claude
  • Invia un piccolo PDF o un'immagine con una didascalia — l'assistente dovrebbe riferirsi al suo contenuto
  • Invia due messaggi di fila, dove il secondo fa riferimento al primo — la memoria multi-turno dovrebbe reggere

Passo 9: Aggiungi altri partecipanti (quando pronto).

  • Ogni partecipante recupera il proprio ID utente Telegram da @userinfobot
  • Aggiorna .env: HERMES_ALLOWED_USERS=<operator_id>,<member1_id>,<member2_id>
  • docker compose restart hermes
  • Crea un gruppo Telegram, invita tutti i membri e il bot, testa con /ping@some_project_assistant_bot

Note operative e rischi noti

Lo stack gira in modo stabile, ma vale la pena essere consapevoli di alcune problematiche.

Cloudflare WAF sul refresh OAuth. L'endpoint di autenticazione di Anthropic si trova dietro Cloudflare. Esiste un problema noto (aperto nel repo anthropics/claude-code) per cui Cloudflare classifica certi IP di server come Linux headless e blocca il refresh OAuth in modo permanente. Alcuni utenti hanno segnalato blocchi della durata di settimane. Il percorso di recupero è ri-autenticarsi da un IP diverso (residenziale, VPN). La mitigazione è evitare user-agent personalizzati e cicli di retry aggressivi, e considerare claude setup-token (un token annuale generato da una macchina autenticata, passato al container tramite CLAUDE_CODE_OAUTH_TOKEN) come fallback se si opera in un range di IP ad alto rischio.

Rotazione a uso singolo del refresh token. I refresh token OAuth di Anthropic sono a uso singolo. Se due client (es. il tuo laptop e il tuo container) condividono lo stesso account e si aggiornano entrambi in parallelo, il primo refresh invalida l'altra sessione. Il consiglio pratico: un account Anthropic dedicato per ogni istanza dell'assistente. Se non puoi farlo, accetta gli occasionali re-login e non eseguire Claude Code sul laptop e nell'assistente contemporaneamente.

La trappola del claude.json vuoto. Se data/claude.json è un file di dimensione zero al primo avvio del container, Claude Code lancia Configuration Error: invalid JSON, Unexpected EOF. Inizializzalo con echo "{}" > data/claude.json, non con touch. L'errore è recuperabile nel REPL («Reset with default configuration»), ma è meglio evitare l'attrito.

Il bug dell'URL OAuth che va a capo (nostra osservazione). L'URL OAuth è lungo circa 530 caratteri. In un terminale di larghezza normale, va a capo su più righe. Quando si copia l'output a capo, gli a capo vengono inclusi, e dopo URL-encoding il parametro scope appare come user:inference us\ner:profile. Cloudflare vede allora us come uno scope e rifiuta con Invalid OAuth Request — Unknown scope: us. Questo non è tracciato come problema upstream al momento della scrittura — l'abbiamo riscontrato noi stessi. Allargare il terminale a 250+ caratteri prima di lanciare claude lo evita, oppure si può de-concatenare manualmente l'URL nella barra degli indirizzi del browser dopo averlo incollato.

Il conflitto UID 1000 di Ubuntu. Alcune immagini base Ubuntu moderne — in particolare ubuntu:24.04 (Noble) dopo il rebase OCI di Canonical — includono un utente ubuntu di default con UID 1000. Se si cambia la base del Dockerfile da python:3.13-slim-trixie (che non include un utente UID-1000 di default) a una che ce l'ha, useradd -u 1000 hermes fallisce con UID 1000 is not unique. Il Dockerfile include un userdel -r ubuntu 2>/dev/null || true difensivo prima di useradd proprio per questo motivo.

Il log delle conversazioni può crescere. I log solo in append in /workspace/conversations/ crescono con ogni messaggio. Nel corso di mesi di utilizzo attivo, i singoli file possono raggiungere diversi megabyte. Non c'è rotazione integrata. Se ti interessa, aggiungi un job stile cron per archiviare i log più vecchi di N giorni, o suddividi per mese.

Per problematiche operative più ampie relative ai servizi self-hosted e a cosa succede quando si guastano, vedi il nostro articolo sul disaster recovery per i servizi self-hosted.

Cosa è deliberatamente fuori dal perimetro

La tentazione quando si costruisce uno strumento AI interno è aggiungere funzionalità. Noi teniamo questo stack piccolo. Le seguenti sono esplicitamente fuori dal perimetro per la versione descritta qui, e abbiamo fatto una scelta attiva di non aggiungerle ancora:

  • RAG / database vettoriale. La conoscenza dell'assistente è in file Markdown (CLAUDE.md e notes/) e nei log delle conversazioni. Le chiamate al Read-tool gestiscono il retrieval. Questo è sufficiente per un perimetro a singolo mandato. Quando il workspace supera una certa dimensione, un vero layer RAG (PostgreSQL + pgvector, per esempio) diventa necessario, ma fino ad allora è eccessivo.
  • Trascrizione audio. I messaggi vocali vengono scaricati e i metadati vengono registrati, ma l'assistente non è ancora in grado di trascriverli. Aggiungere Whisper o una pipeline simile è mezza giornata di lavoro, rinviata fino a quando non sarà necessario.
  • Endpoint di health check. Il container non ha un server HTTP; non c'è niente da interrogare. La restart-policy di Docker insieme al monitoraggio dei log copre la maggior parte delle modalità di guasto.
  • Risposte in streaming. Vedi la Decisione 6 sopra.
  • Multi-tenant su un singolo container. Ogni mandato ottiene il proprio container. Questo è intenzionale — vedi il modello a istanza di progetto sopra.

Le funzionalità rinviate non sono bug. Sono scelte, e riducono la superficie per le cose che esistono. Per un approfondimento sulla disciplina di lasciare che gli strumenti AI restino circoscritti, vedi costruire skill per agenti AI in workflow specifici per dominio e la panoramica sulle Claude Skills.

Clonare per il prossimo mandato

Il design a due variabili d'ambiente si rivela utile qui. Per avviare una nuova istanza:

  1. Clona il repo in /opt/<new-instance-id>
  2. Cambia HERMES_PROJECT_NAME e HERMES_INSTANCE_ID in .env
  3. Registra un nuovo bot con BotFather (una volta sola)
  4. Compila il template del workspace con il contesto del nuovo mandato (una volta sola)
  5. Login OAuth dall'interno del nuovo container (una volta sola, idealmente su un account Anthropic separato)
  6. docker compose up -d

Il codice è bit-identico tra le istanze. Le uniche cose che variano sono le due variabili d'ambiente, le credenziali del bot, il contenuto del workspace e la sessione OAuth.

Si possono eseguire più istanze sullo stesso server. Ognuna ha la propria directory, il proprio nome container, il proprio albero di bind-mount. L'utilizzo del disco è di circa 700 MB per l'immagine (condivisa tra le istanze grazie alla cache dei layer di Docker) più la crescita del workspace per istanza (tipicamente decine di MB dopo mesi).

Il posto di questo strumento nella toolchain

Un assistente AI specifico per progetto non è un sostituto degli strumenti AI generici per la programmazione. Usiamo ancora direttamente Claude Code, Cursor e Gemini CLI per il lavoro di sviluppo. L'assistente è per il contesto di consulenza: memoria di progetto, analisi di documenti, aggiornamenti di stato, ricerche ad hoc all'interno di un mandato definito. Gira in parallelo al resto dello stack di strumenti AI, non al posto di esso.

Il pattern non è nemmeno un sostituto dei prodotti AI basati su chat come Claude.ai o ChatGPT. Quelli sono la risposta giusta per compiti una tantum, domande personali e lavoro di conoscenza generale. Un assistente specifico per progetto è la risposta giusta quando il progetto ha i propri confini, i propri partecipanti e la propria base di conoscenza in evoluzione che non si vuole incollare in un chatbot generico ogni volta che si ha bisogno di consultarla.

Se stai valutando di costruire qualcosa di simile per la tua practice di consulenza, agenzia o team interno, lo stack descritto sopra è un punto di partenza ragionevole. Dockerfile, compose file e struttura dei moduli sono riproducibili da questa guida. Le decisioni sono documentate. Se il tuo contesto differisce dal nostro su una qualsiasi delle sette decisioni, biforca e adatta — il codice è abbastanza breve che riscrivere un modulo è semplice. Contattaci se vuoi confrontare esperienze o hai bisogno di aiuto con un'implementazione specifica.


Articoli correlati

Articoli correlati