Fino a poco tempo fa, per parlare con il mio agente potevo farlo da due posti soli: il terminale del GX10, o VS Code seduto davanti a una delle due macchine. Un agente che risponde solo quando sei alla scrivania è un agente con gli orari d’ufficio.

Così ho costruito gx-telegram: un gateway Python - un solo processo, una sola dipendenza pesante da SQLite - che collega un bot Telegram (@inferont) agli agenti Hermes che girano sul GX10 e sullo SCAR16, e che nel frattempo è diventato anche il registro di tutto quello che faccio con l’agente. Compreso questo blog: i boilerplate del lunedì mattina partono da lì.

L’architettura in trenta secondi

                        ┌────────────────────────── GX10 ────────────────────────────┐
 Telegram ◄──────────►  │  telegram-gateway (un processo)                            │
  (long polling)        │  ├── Telegram poller ──► router ──► TaskManager ──► Hermes │
                        │  ├── HTTP API :30100 ◄────────────── agent REST            │
                        │  └── SQLite (task, agenti, conversazioni, notifiche)       │
                        └──────────────────────────────┬─────────────────────────────┘
                                                       │ LAN HTTP + token condiviso
                                                       ▼
                                     SCAR16: telegram-gateway agent --agent-id scar16
                                     (registrazione, heartbeat, poll task, Hermes locale)

La scelta di design che tiene in piedi tutto: il gateway non sa quali macchine esistono. Non c’è un hostname cablato da nessuna parte. Ogni macchina che vuole eseguire task si presenta da sola - si registra con hostname, OS, modello e capacità, poi fa heartbeat ogni 30 secondi. Se silenziosa da 90, è offline. Domani aggiungo un NAS con --agent-id nas-01 e il gateway non cambia di una riga.

Niente webhook, nemmeno per Telegram: long polling. Il GX10 sta dietro NAT domestico, senza TLS pubblico né porte aperte - il polling funziona ovunque e il Bot API lo supporta nativamente.

Hermes: cosa esegue l’agente

Il gateway non implementa nessun agente: si limita a lanciare la CLI di Hermes in modalità one-shot, hermes -z "<prompt>" --yolo, e legge lo stdout. Nessuna modifica a sorgenti o configurazione di Hermes: il requisito è solo che il binario esista e sia già configurato sulla macchina che esegue. Il gateway è un orchestratore, non un cervello - e questa separazione mi ha risparmiato di reinventare tutto il layer agent ogni volta che cambia modello.

Il ciclo di vita di un task

Un messaggio Telegram non è una richiesta HTTP: un task può durare venti minuti. Quindi niente chiamate sincrone, ma una coda con machine a stati esplicita:

queued → running → completed | failed | cancelled

Sul GX10 il task gira subito come subprocess, con timeout e limite di concorrenza. Per gli agenti remoti, il runner sullo SCAR16 fa poll di GET /agent/tasks, esegue localmente e riporta il risultato. L’annullamento propaga in entrambe le direzioni: /cancel 42 finisce nella risposta del heartbeat successivo, e il runner ammazza il processo Hermes locale.

L’interfaccia Telegram fa da UI: il messaggio ⏳ Task in esecuzione… viene modificato in-place (editMessageText) fino a diventare ✅ Fatto o ❌ Fallito. Niente spam di messaggi intermedi.

Check GPU temperature          → agent gx10 (default)
/scar16 riassumi i log         → coda per lo SCAR16
/status                        → stato gateway + mappa agenti online
/tasks                         → task recenti
/cancel [id]                   → annulla in coda o in esecuzione

Notifiche: non tutto merita di squillare

Un agente chiacchierone è peggio di un agente muto. Il sistema di notifiche incrocia due dimensioni:

  • tipo - success | info | warning | error | progress | task_started | task_completed | task_failed | scheduled_task | startup | shutdown
  • priorità - low | normal | high | critical

Con in più la soppressione dei duplicati (notifiche identiche a distanza di pochi secondi arrivano una volta sola) e una modalità ignore per le low-priority: i progressi ridondanti restano nei log, non sul telefono. È la differenza tra uno strumento che tieni installato e uno che muti dopo due giorni.

Il proxy VS Code: lo specchio delle chat

La parte nata per ultima e finita per diventare la più utile. Tra VS Code e llama-server (Qwen su porta 30000) si è infilato un proxy OpenAI trasparente su porta 30001: le chat di Copilot Chat passano di lì e ogni turno - il mio messaggio e la risposta del modello - viene spinto su Telegram e appoggiato in un feed JSONL.

Strix Scar 16 ─┐                                  ┌─► llama-server :30000
               ├─POST /v1/chat/completions─► proxy :30001
GX10 (locale) ─┘        (bind LAN)                ├─ notify ─► API gateway ─► Telegram
                                                  └─ append ─► activity.jsonl

Tre dettagli che valgono più dell’idea:

  1. Fail-open. Se il gateway è giù o Telegram latita, la chat di VS Code funziona comunque: l’errore di mirror finisce solo nei log. Un osservatore non deve mai poter rompere l’osservato.
  2. Modalità agente gestita. In agent mode il modello emette decine di risposte intermedie con tool call: quelle restano silenziose, su Telegram arriva solo la risposta finale. E le chiamate interne di servizio (title generation con max_tokens minuscoli) vengono scartate a piè di lista.
  3. L’identificazione della macchina è stata una sorpresa. L’idea era distinguere GX10 e SCAR16 tramite l’apiKey che VS Code dichiara - peccato che il client personalizzato di Copilot non inoltri la chiave: arriva Authorization: Bearer vuoto. Ripiegato sull’IP sorgente: il proxy binda la LAN e un mapping ip → macchina risolve l’identità. Funziona finché il DHCP è buono, ovviamente - è il compromesso che sapevo di fare, scritto nero su bianco nel README.

Il feed che scrive i post

L’activity.jsonl non è finito in un angolo: è la fonte dei boilerplate automatici del lunedì. Uno script systemd (blog-draft.timer) legge il feed della settimana, lo compatta per giorno e progetto, lo dà in pasto a Qwen locale con il reasoning disattivato, e deposita una bozza in content/posts/ di questo blog. Anche il post che state leggendo, alla fine, nasce da un’idea passata attraverso quel feed: il loop si chiude.

flowchart LR
    V[VS Code ×2] -->|chat| P[proxy :30001]
    P -->|ogni turno| A[(activity.jsonl)]
    A -->|lun 09:30 timer| G[generate-draft.py]
    G -->|Qwen locale| B[(bozza blog)]
    B -->|draft: false| S[(sito Hugo)]

Sicurezza: cortile, non fortezza

Tutto gira in LAN domestica, ma “domestico” non vuol dire “incustodito”:

  • Allowlist rigida: user ID e chat ID Telegram espliciti - il gateway rifiuta di partire senza token e senza allowlist. Chi non è in lista viene loggato e ignorato.
  • Rate limiting per utente sull’ingresso Telegram.
  • Token condivisi per agente (X-Agent-Token) sugli endpoint REST; gli endpoint admin sono accessibili da localhost a meno che non si configuri un token admin - il default sicuro coincide con il default pigro.
  • Redazione dei segreti: ogni stringa di errore che passa dal gateway verso log o Telegram viene ripulita dai segreti noti. Un traceback che finisce sul telefono non deve portare con sé la chiave dell’API.
  • SQLite in modalità WAL: task e conversazioni sopravvivono ai riavvii, e i task rimasti running a causa di un crash vengono marcati failed allo startup.

Cosa rifarei (e cosa no)

Le scelte che si sono ripagate: il registro di agenti generico (zero attrito per aggiungere macchine), il long polling (niente TLS, niente DDNS, niente porte), e il fail-open del proxy. Il single-process Python con SQLite regge senza battere ciglio un carico che è, realisticamente, “una persona e i suoi agenti”.

Quello che mi ha fatto perdere tempo è Telegram stesso: il 409 Conflict quando c’è un secondo poller attivo (il gateway ora cancella l’eventuale webhook all’avvio, ma ci sono cascato lo stesso), e i 4096 caratteri per messaggio che hanno imposto il troncamento delle risposte lunghe. E l’apiKey sparita nel Bearer vuoto resta la lezione del giorno: mai fidarsi di quello che il client dichiara, verificare quello che consegna.

Prossimo passo naturale: raggiungere il gateway anche da fuori casa via Tailscale, così il telefono parla con i miei agenti in viaggio - senza aprire nulla sulla rete pubblica. Ma questa, eventualmente, è un’altra bozza.