← Indice documentazione Microprogettazione › gateway

myclaw

gateway — il punto di ingresso
Microprogettazione v1.0 — 21 aprile 2026
Primo dei quattro documenti classici della fase 1.
Ora che i tre trasversali sono stabili, si parte dalle fondamenta.

Pubblico: chi scriverà il processo FastAPI di myclaw. Lettura: 20 min.

Indice

  1. Scopo e confini
  2. Topology: dove sta il gateway
  3. Gli endpoint HTTP
  4. Sessioni: ciclo di vita e persistenza
  5. Canali in ingresso: webhook, polling, long-poll
  6. Cron e compiti ricorrenti
  7. Autenticazione: admin vs per-canale
  8. Hardening systemd
  9. Contratto Python
  10. Alternative considerate
  11. Test di conformità
  12. Riferimenti

1. Scopo e confini

Il gateway è il processo di myclaw. Non una libreria, non un insieme di script: un singolo processo FastAPI che parte a boot (via systemd --user), ascolta su 127.0.0.1:42618, e resta vivo finché il PC è acceso. Tutto ciò che succede in myclaw passa da qui.

Cos'è

Cosa non è

2. Topology: dove sta il gateway

RETE DI CASA (LAN + localhost) Esterno (internet) — accesso solo via tunnel esplicito (Tailscale/Cloudflare), opt-in CLI socket local /tmp/myclaw.sock loopback only Telegram long-poll getUpdates outbound only (futuri canali) Signal, voce, MQTT, ... stesso pattern gateway 127.0.0.1:42618 · FastAPI Sessions Webhook Cron Auth HTTP: /health /metrics /ingest /approvals /admin AgentRuntime agent_runtime.html handle(msg, session, channel) → ExecutionTrace invocato dal gateway suprastructure LLM · STT · TTS · embedding via registry.get(...) Workspace workspace/.audit/ MEMORY, IDENTITY, ... in-process outbound API api.telegram.org
Figura 1 — Il gateway al centro. I canali in ingresso parlano solo col gateway. Il gateway parla in-process con l'AgentRuntime. Il runtime, a sua volta, attinge dall'interfaccia LLM e dal workspace. Nessuna comunicazione diretta canali ↔ runtime: il gateway è il solo punto di snodo.
DECISIONE v1: processo singolo, runtime in-process (non microservizi). Motivo: latenza minore, un solo log/audit da gestire, zero inter-process plumbing, restart atomico. Valutare separazione runtime-in-worker se il loop diventa bloccante per il gateway (quando? quasi mai per uso domestico).

3. Gli endpoint HTTP

Tutte le route sono su 127.0.0.1:42618. Nessuna è esposta su 0.0.0.0 di default. L'accesso da altro host della LAN (o remoto via tunnel) richiede reverse-proxy esplicito, fuori scope di questo doc.

Endpoint pubblici (per canali)

MetodoPathCosa fa
POST /ingest/cli Canale CLI locale invia un messaggio. Body: {session_id, text}. Ritorna ExecutionTrace id (per sse).
POST /ingest/telegram Webhook-style, ma in realtà chiamato dal worker di long-poll interno al gateway. Body: Telegram Update JSON.
SSE /ingest/<channel>/stream/<trace_id> Server-Sent Events: stream del final_response token-by-token, utile per canali che supportano streaming progressivo (CLI).

Endpoint approvazioni (usati dai canali per risolvere le ApprovalRequest)

MetodoPathCosa fa
GET /approvals/pending?sender=<id> Lista ApprovalRequest pendenti per un sender.
POST /approvals/<request_id>/grant Concede. Body: {sender, via: "explicit"|"batch", batch_window_min?: int}.
POST /approvals/<request_id>/deny Rifiuta. Body: {sender, permanent?: bool}. Se permanent=true → never_list.
POST /undo?sender=<id> Revoca ultima azione del sender. Vedi approval_ux §6.

Endpoint admin

MetodoPathCosa fa
GET/healthStato: uptime, version, memoria, ultimo errore.
GET/metricsMetriche Prometheus-like: richieste, costo giornaliero, trace count, sessioni attive.
GET/admin/sessionsLista sessioni attive (richiede token).
GET/admin/pairingsLista sender pairati per canale.
POST/admin/pairings/approveApprova un pairing code. Body: {channel, code, as_role}.
GET/admin/cronLista cron job schedulati.
POST/admin/cronAggiunge cron job. Body: {when, intent, channel}.
GET/admin/audit?since=&limit=Legge l'audit log JSONL filtrato.
GET/admin/budgetMostra consumo giornaliero vs soft/hard cap.

Endpoint interni

MetodoPathCosa fa
WS/ws/internalSolo per il worker CLI: WebSocket bidirezionale per streaming interattivo del loop ReAct (thought + tool_call intermedi).

4. Sessioni: ciclo di vita e persistenza

Una sessione è una conversazione logica con un sender su un canale. Ha uno stato (memoria di chat, autonomy corrente, batch attivi) e un ciclo di vita. Il gateway le gestisce in memoria con write-through su SQLite.

Identificazione

session_id := hash("{channel}:{sender_id}:{autonomy_level}")
# esempio: hash("telegram:@roberto:supervised") = "s_4a7b..."

La stessa persona su due canali = due sessioni distinte. Lo stesso sender che cambia autonomy = nuova sessione (la vecchia diventa archived). Questo mantiene chiaro il confine di autonomia.

Binding con la memoria

Per evitare confusione cross-doc, fissiamo qui la chiave di recupero usata da memory:

Livello di memoriaChiave di indicizzazioneCiclo di vita
workingtrace_idvive e muore con la ExecutionTrace del turno
episodicsender (canale-agnostico: telegram:@rob e cli:roberto condividono episodic se mappati alla stessa identità utente)persiste oltre la sessione, decade Ebbinghaus (vedi synapse)
semantic e coreglobale, nessuna chiavevive per sempre salvo reflection + approvazione

Il Gateway espone Session.sender come unica chiave che la Memory deve leggere; non espone session_id alla Memory (la session è un oggetto di conversazione, non di identità).

Stati

New primo messaggio Active loop in corso o risposta Idle aspetta nuovi input Closed 30 min idle o /bye risposta inviata timeout o /bye nuovo messaggio cambio autonomy → archivia, nasce nuova
Figura 2 — Gli stati di una sessione. Timeout default di 30 minuti da Idle a Closed. Il cambio di autonomy durante una sessione archivia la corrente e ne crea una nuova (linea tratteggiata rossa).

Persistenza

In memoria per il ciclo di vita, con write-through su SQLite per sopravvivere ai restart del gateway:

workspace/state/sessions.db          # SQLite
  └─ sessions(session_id, channel, sender, autonomy, created, last_activity, closed_at)
  └─ messages(session_id, step_idx, role, content, trace_id, timestamp)
  └─ batches(batch_id, session_id, effect_class, expires_at)
Crash recovery fase 1: se il gateway crasha durante una risposta, la sessione viene ricostruita al restart in stato Idle dall'ultimo messaggio completato. Il turno in corso è perso; l'utente vede "myclaw si è riavviato, puoi ripetere?". Non è perfetto ma è semplice. Persistenza crash-safe dei turni in corso è rimandata (vedi giudizio §4 #13).

4.1 Flusso approvazione: chi costruisce ApprovalRequest

La revisione incrociata dei doc ha trovato un buco di responsabilità: la ApprovalRequest (approval_ux §9) è il dato chiave del flusso, ma né la Policy né il broker la costruiscono. È il Gateway, e qui è il motivo: solo il Gateway conosce insieme il session_id, il trace_id in costruzione, il canale e il sender attivo. La Policy non ha queste informazioni contestuali; il broker è un consumatore puro.

agent_runtime tool_call Policy.evaluate() → verdict + draft Gateway build_approval_request() arricchisce il draft ApprovalBroker request(req) → Approval Channel present_approval() tc draft request render Policy produce il draft (cosa/perché). Gateway produce la request (chi/dove/quando). Broker la consegna. Channel la mostra.
Figura 2b — La catena di costruzione di ApprovalRequest. Solo il Gateway ha il contesto completo (trace, canale, sender, session, timeout).
# Pseudocodice del Gateway (vive accanto a ingest()):
def _on_policy_approve_required(
    self,
    draft: ApprovalRequestDraft,      # da Policy
    trace_id: UUID,
    session: Session,
) -> ApprovalRequest:
    return ApprovalRequest(
        request_id=uuid4(),
        trace_id=trace_id,
        sender=session.sender,
        channel=session.channel,
        tool_name=draft.tool_name,
        args=draft.args_preview,              # redacted
        effect_class=draft.effect_class,
        summary=draft.summary,                # preferibilmente da tool.dry_run()
        reversibility=draft.reversibility,
        risk=draft.risk,
        requested_at=now_utc(),
        timeout_at=now_utc() + channel.default_approval_timeout(),
        # fonte: il Channel possiede il default per-canale (vedi approval_ux §8).
    )

async def _await_approval(self, req: ApprovalRequest) -> Approval:
    # Il broker si occupa di batching, tutor mode, pausa lettura, revoca:
    return await self.broker.request(req)
DECISIONE v1 (owner unico del flusso): il Gateway è l'unico componente autorizzato a costruire una ApprovalRequest e a chiamare ApprovalBroker.request(). La Policy emette un draft; il broker consuma la request finale. Questo chiude il buco B4 della revisione incrociata di fase 1.

5. Canali in ingresso: webhook, polling, long-poll

Tre strategie, una per tipo di canale. Il gateway ne orchestra fino a N in parallelo, ciascuna in un task asyncio dedicato.

CanaleStrategiaDettaglio
CLI unix socket + HTTP Il binario myclaw chat apre una connessione a /tmp/myclaw.sock. Parla HTTP sul socket, con upgrade a WebSocket per lo streaming del loop (thought + tool_call mostrati in tempo reale). Latenza <5ms.
Telegram long-poll outbound Un task asyncio in loop chiama getUpdates di Telegram Bot API. Gli update vengono tradotti in POST interni a /ingest/telegram. Nessun webhook pubblico: non serve esporre myclaw a internet, evita problemi di reachability e TLS.
Voce (futuro) push via sibling Un assistente domotico (sibling agent che gestisce voce e domotica) invia trascrizioni STT a POST /ingest/voice. Il gateway risponde con testo che il sibling sintetizzerà via TTS. Il full duplex della conversazione vocale resta gestito dal sibling; myclaw si limita al ragionamento sul testo.
MQTT (futuro) subscriber Un task subscribe a topic myclaw/inbox/#. Risponde su myclaw/outbox/<origin>. Integrazione con Home Assistant.

Come i canali parlano col gateway in risposta

Il gateway non mantiene una connessione bidirezionale coi canali esterni (Telegram, MQTT). Il canale ha un proprio task che, oltre a ricevere, sa anche rispondere via API sua (es. sendMessage di Telegram). Il runtime, a fine loop, passa il final_response al channel adapter via coda asyncio. Il channel adapter fa la chiamata outbound verso il mondo.

Perché non webhook inbound per Telegram: evita TLS pubblico, reverse proxy, DNS dinamico. Un long-poll interno consuma 1 richiesta ogni 25s, banda trascurabile. Scala bene per uso singolo-famiglia.

6. Cron e compiti ricorrenti

Il gateway ha uno scheduler interno basato su APScheduler (o equivalente asyncio-native). Gli utenti aggiungono job via comando (myclaw cron add "ogni sera 22:00" "riassumi log di oggi").

Semantica del cron myclaw

Un cron job non è un comando shell. È un'intenzione che, a tempo stabilito, viene iniettata come messaggio utente in una sessione dedicata:

CronJob(
  id="cj_daily_22",
  when="0 22 * * *",
  channel="telegram:@roberto",
  autonomy="supervised",          # stessa policy di un messaggio utente
  intent="riassumi gli errori in /var/log/syslog di oggi, se ce ne sono notificami"
)

Alle 22:00, il gateway crea una sessione cron-owned, inietta l'intent come messaggio utente, lascia che l'agent_runtime risponda. Se la risposta contiene "notifica", il canale dedicato la recapita. Se una tool-call richiede approvazione e Roberto dorme, la richiesta va in Pending con timeout di 2h.

Storage

workspace/state/cron.yaml          # file YAML editabile anche a mano
  jobs:
    - id: cj_daily_22
      when: "0 22 * * *"
      channel: "telegram:@roberto"
      intent: "..."
      created_at: "2026-04-21T18:00:00Z"
      last_run: "2026-04-20T22:00:00Z"
      last_outcome: "success"

7. Autenticazione: admin vs per-canale

Admin API

Un singolo bearer token in config/secrets.env:

MYCLAW_ADMIN_TOKEN=<32 random bytes in hex>

Richiesto in header Authorization: Bearer <token> su ogni path /admin/*. Il CLI lo legge dal file (chmod 600) alla partenza. Niente OAuth, JWT, o roba complessa: è un singolo token per un singolo amministratore (Roberto) su un singolo host. Semplicità batte sofisticazione.

Canali

CanaleMeccanismo
CLIAccesso al socket richiede uid matching con roberto. Chmod 600 sul socket. Nessun token.
Telegram Il gateway identifica il sender dal campo from.id dell'Update. Lo confronta con la pairing list (pairing.html). Sender non pairato → pairing flow.
Bot token Telegram in secrets.env.
Voce (futuro)L'assistente domotico sibling autentica l'utente via speaker ID (servizio fornito dall'interfaccia LLM); passa il sender_id al gateway con HMAC sul payload.
Non esporre mai il gateway su 0.0.0.0 senza reverse-proxy con auth aggiuntiva. Il bearer token protegge /admin, ma /ingest/cli assume socket Unix con controllo di uid. Bind su 0.0.0.0 equivale a dare /ingest/cli a chiunque sulla LAN.

8. Hardening systemd

Il gateway gira come systemd user service per roberto. L'unità è pesantemente indurita: myclaw deve fare cose potenti (vedi nota operativa di Roberto: "sandbox abbastanza potente da permettere azioni concrete"), ma il gateway stesso non deve avere più privilegi di quelli necessari ad ascoltare HTTP e invocare la sandbox.

# ~/.config/systemd/user/myclaw-gateway.service
[Unit]
Description=myclaw gateway
After=network.target

[Service]
Type=simple
ExecStart=/opt/myclaw/.venv/bin/python -m myclaw.gateway
Restart=on-failure
RestartSec=5s

# ---- Hardening ----
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/opt/myclaw/workspace /opt/myclaw/.runtime
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=yes
RestrictRealtime=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM

# ---- Limits ----
LimitNOFILE=4096
MemoryHigh=512M
MemoryMax=1G

# ---- Env ----
EnvironmentFile=/opt/myclaw/config/secrets.env

[Install]
WantedBy=default.target

Scelte giustificate

DirettivaPerché
NoNewPrivileges=yesNessun processo figlio può guadagnare privilegi (setuid, file capabilities). Fondamentale dato che il gateway spawna sandbox.
ProtectSystem=strictTutto il filesystem è read-only tranne ReadWritePaths. Workspace scrivibile, tutto il resto no.
ProtectHome=read-onlyLa home di Roberto è leggibile ma non modificabile dal gateway stesso. La sandbox invocata può avere permessi diversi (gestiti in sandbox.html).
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6Niente raw socket, niente AF_NETLINK. Solo TCP/IP (per chiamate outbound a LLM/Telegram) e Unix socket (CLI).
MemoryMax=1GHard cap. Se il gateway sale oltre, OOM-kill e restart. Previene leak di contesto.
SystemCallFilter=@system-serviceBlocca syscall esotiche (ptrace, io_uring, keyctl...) non necessarie.
Nota operativa (da Roberto): queste restrizioni si applicano al gateway, non alla sandbox che esegue i tool. La sandbox (sandbox.html) avrà i suoi profili bwrap dimensionati per lasciare a myclaw la potenza effettiva di agire nel workspace e in aree operative definite. Separazione netta: gateway stretto, sandbox ampia entro limiti esplicitati.

9. Contratto Python

from typing import Protocol
from fastapi import FastAPI
from dataclasses import dataclass
from datetime import datetime
from uuid import UUID

@dataclass
class Session:
    session_id: str
    channel: str
    sender: str
    autonomy: str                 # "readonly" | "supervised" | "full"
    created_at: datetime
    last_activity: datetime
    state: str                    # "new" | "active" | "idle" | "closed"
    pending_approvals: list[UUID]

class Gateway(Protocol):
    app: FastAPI
    runtime: AgentRuntime         # iniettato
    sessions: SessionStore
    broker: ApprovalBroker
    scheduler: CronScheduler

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

    async def ingest(
        self,
        channel: str,
        sender: str,
        message: str,
    ) -> ExecutionTrace:
        """Punto d'ingresso unificato usato da tutti gli adapter canale."""
        ...

class SessionStore(Protocol):
    async def get_or_create(
        self, channel: str, sender: str, autonomy: str,
    ) -> Session: ...
    async def close(self, session_id: str) -> None: ...
    async def archive_on_autonomy_change(
        self, session_id: str, new_autonomy: str,
    ) -> Session: ...

class CronScheduler(Protocol):
    async def add(self, job: CronJob) -> str: ...    # ritorna job_id
    async def remove(self, job_id: str) -> None: ...
    async def list_jobs(self) -> list[CronJob]: ...
    async def run_now(self, job_id: str) -> ExecutionTrace: ...

10. Alternative considerate

AlternativaPerché scartata
Gateway + runtime come microservizi separati Latenza IPC, doppio audit, doppio log, due unità systemd, zero beneficio a scala casa. Process singolo è la scelta giusta.
gRPC invece di HTTP/REST Nessun consumer ne ha bisogno. HTTP è ispezionabile con curl, loggabile con tcpdump, sufficiente per l'uso.
Webhook pubblico Telegram (reverse proxy + Let's Encrypt) Richiede TLS su router, DDNS, rinnovi cert. Long-poll è 20 righe di codice e zero infra pubblica.
OAuth/OIDC per admin API Popolazione di 1 utente amministratore. Bearer token statico batte OAuth per semplicità.
Cron di sistema (crontab) Perde la semantica "inject intent as user message in session": cron di sistema esegue shell, non dialoga con l'agente. Il nostro APScheduler è la glue corretta.
WebSocket per tutti i canali Overkill per canali stateless (Telegram). Usiamo WS solo dove il bidirezionale ha senso (CLI streaming del loop).

11. Test di conformità

InvarianteTest
Bind solo su 127.0.0.1Avvio con config default → ss -tlnp mostra socket solo loopback. Bind su 0.0.0.0 richiede override esplicito + warning.
Admin endpoint richiede tokenGET /admin/sessions senza header → 401. Con token corretto → 200.
Sessione nasce alla prima ingestPOST /ingest/cli con sender nuovo → session_id creato, state=new → passa a active durante il turno.
Cambio autonomy archivia e ricreaSessione S in autonomy supervised. Cambio → S diventa closed (archived), nasce S2 con stesso sender/channel ma autonomy nuova.
Idle timeout dopo 30 minSessione senza attività per 30:01 min → stato passa a closed.
Crash recovery sessionKill -9 del gateway durante turno attivo. Restart → sessione è idle (non attiva), ultimo messaggio completato visibile.
Cron inject come user messageJob con intent "test cron" scatta → nell'audit log compare un turno con channel="cron:<job_id>" e messaggio utente "test cron".
Approval endpoint non accetta sender spoofingPOST /approvals/<id>/grant con sender diverso dal sender che ha originato la richiesta → 403.
SSE stream chiude a completamentoClient connesso a /ingest/.../stream/<trace_id> riceve eventi token, poi evento event: complete, poi close.
Systemd restart su crashCrash del processo → systemd riavvia entro 5s. /health risponde di nuovo.
Budget hit blocca nuovi turniConfig con hard_cap a 0.01€ raggiunto → POST /ingest/* ritorna 429 con messaggio esplicativo.

12. Riferimenti

RiferimentoCosa abbiamo preso
supra-gateway (pattern di sibling project /opt/suprastructure/gateway/)Convenzioni di porta, systemd unit, health endpoint. Stile coerente in casa.
FastAPI docs (SSE, WebSocket, background tasks)Patterns per streaming e long-running tasks.
Telegram Bot API (getUpdates long-polling)Strategia di ingestione senza webhook pubblico (§5).
systemd.exec(5) — sandbox directivesTutte le direttive hardening del §8.
OpenHands gateway designPattern di separazione tra ingest, runtime, persistence.
Giudizio di fase 0 — critica #4 status visibilitySSE e WS per mostrare il thinking in real time ai canali (§3).

Continua a leggere

prossimo · pianificato
channel (prossimo)
Il Protocol Channel e le due prime implementazioni (CLI e Telegram). Lato adapter dei canali che il gateway orchestra.
microprogettazione · 25 min
agent_runtime
Quello che il gateway chiama in-process. La coppia gateway ↔ runtime è la spina dorsale.
indice microprogettazione
Torna alla landing
Dei 4 classici ne resta 3: channel, tool, sandbox.
home
← Indice documentazione
Tutti i documenti.

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