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:
- 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.
- 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_tokensminuscoli) vengono scartate a piè di lista. - L’identificazione della macchina è stata una sorpresa. L’idea era
distinguere GX10 e SCAR16 tramite l’
apiKeyche VS Code dichiara - peccato che il client personalizzato di Copilot non inoltri la chiave: arrivaAuthorization: Bearervuoto. Ripiegato sull’IP sorgente: il proxy binda la LAN e un mappingip → macchinarisolve 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
runninga causa di un crash vengono marcatifailedallo 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.
