← Indice documentazione Microprogettazione › channel

myclaw

channel — adapter dei canali
Microprogettazione v1.0 — 21 aprile 2026
Secondo dei quattro documenti classici di fase 1.

Pubblico: chi implementerà CLI e Telegram adapter. Lettura: 18 min.

Indice

  1. Scopo e il requisito status-visibility
  2. Il Protocol Channel
  3. CLI: Unix socket + WebSocket streaming
  4. Telegram: long-poll + inline buttons
  5. Come si mostra "sto pensando": per canale
  6. Formattazione dei messaggi
  7. Contratto Python
  8. Alternative considerate
  9. Test di conformità
  10. Riferimenti

1. Scopo e il requisito status-visibility

Un Channel è l'adapter tra un protocollo di messaging esterno e il gateway. Fa due mestieri: ingest (traduce input esterno in ingest(channel, sender, message)) e render (traduce la risposta di myclaw nelle convenzioni del protocollo). Il requisito non opzionale di fase 1 è status visibility: nessun utente aspetta muto.

DECISIONE v1 (da critica #4 del giudizio): ogni canale deve mostrare lo stato "sto pensando / sto eseguendo X" durante il loop ReAct. Un canale che non lo fa non è completo. È un requisito, non un nice-to-have.

Cosa copre questo doc

Cosa non copre

2. Il Protocol Channel

Un Channel è un oggetto asincrono con due responsabilità: pump input verso il gateway, push output verso il mondo. Espone capability flag che il gateway usa per dimensionare l'esperienza (es. "supporta bottoni inline?" → approvazioni con UI ricca vs testo puro).

from typing import Protocol, Literal
from dataclasses import dataclass
from uuid import UUID

@dataclass
class ChannelCapabilities:
    supports_inline_buttons: bool     # Telegram, Signal: sì. CLI: no.
    supports_streaming: bool          # CLI WS: sì. Telegram: edit message.
    supports_typing_indicator: bool   # Telegram: sì. CLI: simulato con spinner.
    supports_markdown: Literal["none", "basic", "full"]
    supports_voice: bool              # futuro
    max_message_length: int           # Telegram: 4096
    max_messages_per_minute: int      # rate-limit outbound

class Channel(Protocol):
    name: str                         # "cli" | "telegram" | ...
    capabilities: ChannelCapabilities

    async def start(self, gateway: Gateway) -> None:
        """Avvia pump (long-poll, listener, etc)."""
        ...

    async def shutdown(self) -> None: ...

    async def send(
        self,
        sender: str,
        content: OutboundMessage,
    ) -> str:
        """
        Invia un messaggio al sender. Ritorna un message_id opaco che
        permette edit successivi (per status updates e approval buttons).
        """
        ...

    async def edit(
        self,
        sender: str,
        message_id: str,
        content: OutboundMessage,
    ) -> None: ...

    async def show_typing(self, sender: str, seconds: float = 3.0) -> None:
        """Mostra indicatore di attività. No-op se capability lo vieta."""
        ...

    async def present_approval(
        self,
        sender: str,
        request: ApprovalRequest,
    ) -> str:
        """Renderizza una ApprovalRequest secondo le capability del canale."""
        ...

    def default_approval_timeout(self) -> timedelta:
        """
        Timeout di default per una richiesta di approvazione su questo canale.
        CLI: 30s · Telegram DM: 2m · canale di casa: 5m · voce: 15s.
        Fonte canonica della tabella: approval_ux.html §8.
        Il Gateway legge da qui, non hardcoda valori.
        """
        ...

Message model: OutboundMessage

@dataclass
class OutboundMessage:
    text: str
    format: Literal["plain", "markdown"] = "plain"
    buttons: list[Button] | None = None      # None se no inline
    replace_previous: bool = False           # vedi nota sotto
    replace_target: str | None = None        # message_id da rimpiazzare
    attachment: Attachment | None = None     # file / image, futuro

@dataclass
class Button:
    label: str
    callback_data: str
    style: Literal["primary", "secondary", "danger"] = "secondary"
    disabled_until_ms: int = 0               # pausa lettura approval_ux §5
Semantica di replace_previous. Quando True, il Gateway passa il message_id dell'ultimo messaggio inviato (proveniente da send() precedente) in replace_target. Il canale sceglie l'implementazione più adatta al suo protocollo: Chi sceglie send+edit vs operazione atomica è il canale, non il Gateway. Il Gateway dichiara l'intento; il canale traduce.

3. CLI: Unix socket + WebSocket streaming

Il canale CLI è quello che Roberto userà dal terminale del PC. Massima interattività, supporto streaming, latenza sub-5ms.

Architettura

  1. Binario myclaw chat (in src/myclaw/cli/chat.py).
  2. Alla partenza, connette a /tmp/myclaw.sock (Unix domain socket, chmod 600, uid check).
  3. Parla HTTP sul socket (richiesta iniziale) + upgrade a WebSocket su /ws/internal.
  4. Il WebSocket riceve eventi tipizzati del loop ReAct: thought_start, thought_partial_text, tool_call_start, tool_call_result, final_response.
  5. Li renderizza con spinner + print progressivi.

Rendering del loop in CLI

$ myclaw chat
myclaw · supervised · session s_4a7b...

> riassumi gli errori di stamattina

◐ sto cercando il log di systemd...
  tool: shell_run(journalctl --since "today" --priority err)
✓ letto (42 righe, 1.3KB)

◐ sto riassumendo...

Stamattina ci sono stati 3 errori:
- alle 08:14, wpa_supplicant ha perso l'associazione (ri-collegato alle 08:14:30)
- alle 09:22, docker ha fallito a pullare un'immagine (network timeout)
- alle 11:04, smartd ha rilevato un contatore SMART anomalo su /dev/sda

>

Lo spinner (◐◓◑◒) gira durante ogni passo. Il nome del tool è sempre esposto (§5). I tool_result sono riassunti in una riga.

Streaming del final_response

Il testo della risposta finale arriva in streaming token-per-token (SSE da agent_runtime, inoltrato via WS). Il CLI stampa on-the-fly per l'effetto "l'agente sta parlando". Latenza prima-lettera < 1s su frontier model.

4. Telegram: long-poll + inline buttons

Bot setup

  1. Creazione bot via BotFather, ottenimento token.
  2. Token in config/secrets.env come MYCLAW_TELEGRAM_TOKEN.
  3. Bot privacy mode ON (il bot riceve solo messaggi che lo menzionano esplicitamente in gruppo).
  4. Comandi registrati: /start, /help, /undo, /bye.

Ingestion loop

# src/myclaw/channels/telegram.py (schematico)
async def pump(self, gateway):
    offset = 0
    while not self._stopped:
        updates = await self._api("getUpdates", offset=offset, timeout=25)
        for upd in updates:
            offset = upd["update_id"] + 1
            if "message" in upd:
                await self._handle_message(gateway, upd["message"])
            elif "callback_query" in upd:
                await self._handle_callback(gateway, upd["callback_query"])

Presentazione di una ApprovalRequest in Telegram

Il canale riceve present_approval(sender, request) dal gateway e compone un messaggio con inline buttons secondo le specifiche di approval_ux §4:

# Messaggio:
Vuoi che scarichi?
📄 rapporto_marzo.pdf
da drive.google.com
→ ~/downloads/ (~2.4 MB)
*reversibile · classe: fs_write:~/downloads/**

# Inline keyboard:
[✅ attendi 3s...]  (disabled, timer sostituisce progressivamente)
[❌ No]
[✓ approva classe per 10 min]

Il "disabled per 3s" è simulato editando il messaggio dopo 3s con il pulsante abilitato (Telegram non ha veri "disabled buttons", si fa con l'edit). Contatore visibile all'utente: "attendi 2s...""attendi 1s...""Approva ✅".

Outbound rate limit

Telegram accetta max ~30 messaggi/secondo. Il canale fa il throttling interno con una token-bucket queue. Per un singolo utente in casa questo non sarà mai un problema, ma il codice è ready per aggiungere famiglia.

5. Come si mostra "sto pensando": per canale

Il gateway emette eventi del loop in tempo reale. Ogni canale ha la responsabilità di tradurli in feedback visibile all'utente. La tabella seguente mostra, per tipo di evento, cosa fa ciascun canale. Questa è la reificazione concreta della critica #4.

Evento runtimeCLITelegram
thought_start Spinner + "penso..." Typing indicator + messaggio "penso..." (edit in-place)
tool_call_start(name, args_summary) "◐ <name>(<summary>)..." Edit messaggio: "🔧 name · summary..."
tool_call_result(outcome_summary) "✓ <outcome>" (o ✗ se errore) Edit: "✅ outcome" (o ❌)
final_response (streaming) Stampa token-by-token Edit progressivo del messaggio ogni ~500ms
approval_required(req) Blocca prompt, mostra richiesta strutturata, attende input [y/N/b/never] Nuovo messaggio con inline buttons
policy_denied(reason) "⛔ reason" "⛔ reason"
budget_hit "⚠ budget giornaliero esaurito" "⚠ budget giornaliero esaurito"
Regola: nessun evento intermedio viene taciuto. Anche se il loop produce 5 tool_call, l'utente vede 5 aggiornamenti. La trasparenza continua è un antidoto contro la sensazione di "cosa sta facendo adesso?" che nei chatbot lenti provoca abbandono.

6. Formattazione dei messaggi

ElementoCLITelegram
Enfasi*testo* (ANSI bold)Markdown V2: *testo*
MonospazioANSI gray backgroundBacktick: `testo`
LinkURL nudoMarkdown V2 [label](url)
Codice blockIndent + ANSI color```python
EmojiUnicode (terminale supportato)Unicode, volutamente sobrio
Separatori--- → riga orizzontaleNewline doppia

Il runtime emette testo in un formato canonico interno (chiamato MyclawMD: sottoinsieme markdown + marker speciali per status). Il canale traduce.

7. Contratto Python

Oltre ai Channel e tipi già definiti sopra:

class ChannelRegistry(Protocol):
    """Registra le implementazioni Channel attive al boot del gateway."""
    def register(self, channel: Channel) -> None: ...
    def get(self, name: str) -> Channel: ...
    def list_all(self) -> list[Channel]: ...

class ChannelEvent(Protocol):
    """Evento che il canale riceve dal gateway per aggiornare l'utente."""
    event_type: str
    trace_id: UUID
    sender: str
    payload: dict

# Tipi di evento emessi dal runtime verso i canali:
# - thought_start
# - thought_stream(partial_text)
# - tool_call_start(name, args_summary)
# - tool_call_result(outcome_summary, error=None)
# - approval_required(request_id, request)
# - approval_resolved(request_id, granted, via)
# - final_response_stream(partial_text)
# - final_response_complete(text)
# - policy_denied(reason)
# - budget_hit(current, hard_cap)
# - trace_closed(outcome, cost, wall_ms)

Eccezioni

EccezioneQuando
ChannelTransientErrorErrore temporaneo (timeout, 5xx). Retry con backoff.
ChannelPermanentErrorToken scaduto, bot kicked, sender blocked. Notifica admin.
MessageTooLongErrorIl testo supera max_message_length. Il runtime dovrebbe dividere.
RateLimitErrorRate-limit hit. Backoff automatico.

8. Alternative considerate

AlternativaPerché scartata
pyrogram / aiogram per Telegram Belle libreria ma aggiungono deps grandi. Il sottoinsieme di Bot API che ci serve (5 chiamate) si implementa in ~200 righe. Ridurre dipendenze paga in manutenzione.
Webhook pubblico Telegram Già escluso in gateway §5: richiede TLS pubblico, reverse-proxy, DDNS. Long-poll è equivalente con 1/1000 del setup.
TUI ricca (Textual, prompt-toolkit) Bello ma fuori scope. Un loop readline + ANSI codes è sufficiente. Un vero TUI diventerebbe una UI secondaria da mantenere.
Un solo Channel monolitico con "mode" dinamico Invece di CLI/Telegram come classi separate, un Channel unico con mode="cli" o mode="telegram". Rifiutato: le astrazioni comuni sono lievi, le differenze sono tante. Separazione per impl è più chiara.
Signal/WhatsApp come fase 1 Signal richiede signal-cli o API fragili. WhatsApp richiede Business API complessa o soluzioni instabili. Rimandati a fase 4+ quando servono.

9. Test di conformità

InvarianteTest
capabilities esposte correttamenteInstanziare ogni Channel e verificare che capabilities.supports_* matchi il comportamento reale.
CLI respinge connessioni da altri uidMock client con uid diverso da roberto → connessione rifiutata, log warning.
Telegram non reagisce a privacy-mode-disabledMock update "gruppo, no mention" → ignorato silenziosamente se privacy_mode=on.
Status visibility: ogni tool_call_start è resoFixture con 3 tool call in sequence → canale deve mostrare 3 aggiornamenti. Assente = fail.
Pausa lettura rispettata nei bottoni TelegramBottone "Approva" disabled 3s (simulato via edit). Click durante 3s → 400 dal gateway (mismatch).
Streaming final_responseClient CLI collegato al WS riceve almeno 3 eventi final_response_stream per risposta > 500 token.
Rate limit outboundForzare 50 send in 1s → token bucket rallenta a 30/s, nessuna 429 da Telegram.
Split messaggio lungoRisposta da 6000 caratteri per Telegram → canale spezza in 2 messaggi contigui, nessuno > 4096.
ChannelPermanentError propagatoBot kicked → canale solleva, gateway logga, admin notificato via CLI status.
Shutdown pulitomyclaw-gateway restart → tutti i canali chiudono i loro pump entro 5s.

10. Riferimenti

RiferimentoCosa abbiamo preso
Telegram Bot API (getUpdates, inline keyboards)La pipeline long-poll (§4) e il rendering dell'approvazione.
Giudizio di fase 0 — critica #4 status visibilityIl mandato "ogni canale deve mostrare lo stato". Reificato in §5.
Nielsen, 10 Usability Heuristics (in particolare "visibility of system status")Radice teorica della §5.
ANSI escape codes (ECMA-48)Per il rendering CLI.
approval_ux.html §4 e §5Le regole di batching e pausa lettura che il canale deve applicare.

Continua a leggere

prossimo
tool (prossimo)
Il Protocol Tool e la base set: fs, shell, web_fetch, supra adapters. Cosa myclaw sa fare concretamente.
microprogettazione · 20 min
gateway
Chi orchestra i canali. La coppia gateway ↔ channel è dove i messaggi vivono.
microprogettazione · 20 min
approval_ux
Il flusso di approvazione che ogni canale deve rendere fedelmente.
indice microprogettazione
Torna alla landing
2 classici su 4 fatti. Restano tool e sandbox.

myclaw — channel microprogettazione v1.0 — 2026-04-21
Secondo dei 4 classici. Prossimo: tool.html.