1. Dove compaiono i «flussi» nell’architettura di ChatGPT App
Prima di discutere cosa sia meglio — SSE o HTTP-stream — è utile capire dove nel nostro stack esistono i flussi.
In modo approssimativo si possono distinguere tre livelli.
Primo, il livello della ChatGPT e del modello. Il modello di per sé già trasmette la risposta a token: si vede il testo della risposta «digitarsi» lettera dopo lettera. Anche questo è un flusso, ma è completamente gestito da OpenAI e non riguarda direttamente il vostro codice.
Secondo, il livello MCP. Quando ChatGPT si collega al vostro server MCP, in genere mantiene una connessione SSE: il server invia (push) al suo interno i messaggi MCP JSON‑RPC (risposte e notifiche) e ChatGPT, in risposta, invia richieste verso un endpoint HTTP separato, per esempio /messages. Nei termini MCP questo è il trasporto di base.
Terzo, il livello dell’Apps SDK e del vostro backend. Il vostro widget React GiftGenius è eseguito nella sandbox di ChatGPT e comunica con il vostro backend/MCP‑gateway via HTTP: tramite un normale fetch, tramite fetch con flusso (ReadableStream) o tramite sottoscrizione SSE (EventSource).
È importante non mescolare questi livelli. Gli eventi MCP sono il «filo» tra ChatGPT e i server; mentre SSE/HTTP-stream tra il widget e il vostro backend HTTP — è la vostra tratta.
Possiamo rappresentarlo con uno schema.
flowchart TD
subgraph ChatGPT
UI[ChatGPT UI + modello]
W[GiftGenius Widget]
end
subgraph YourInfra[Infrastruttura dello sviluppatore]
GW[MCP Gateway / Backend]
MCP[MCP Server]
end
UI -- "tool-call / risposte\n(stream interno di token)" --> W
UI <-- "MCP su SSE\n(/sse + /messages)" --> MCP
W <-- "HTTP / fetch / SSE / stream" --> GW
GW <-- "JSON-RPC MCP" --> MCP
Oggi ci concentreremo sulla freccia Widget ↔ Backend e ricorderemo in parte che anche il trasporto MCP è basato su SSE.
È proprio su questa tratta — Widget ↔ Backend — che dobbiamo scegliere come comunicare: con semplici richieste HTTP o con flussi. Nella sezione successiva vedremo perché l’HTTP «normale» qui smette presto di bastare.
2. Perché una semplice richiesta HTTP non basta
Il modello standard di HTTP è «richiesta → una sola risposta». Il client chiede qualcosa, il server risponde una volta e la connessione si chiude.
Per molti compiti è sufficiente: ottenere lo stato corrente di un job, salvare le impostazioni dell’utente, prendere un elenco di regali già pronto che è in database.
Ma non appena eseguite un’operazione lunga, tutto inizia a scricchiolare.
Immaginate GiftGenius che:
- raccoglie segnali da più fonti (storico acquisti, wishlist, social),
- li elabora con un paio di richieste LLM,
- costruisce un ranking personalizzato da un centinaio di candidati.
Tutto ciò può richiedere decine di secondi. Se tenete una normale richiesta HTTP per 40 secondi senza inviare nulla, l’UX sarà come nei vecchi browser: l’utente guarda una rotellina che gira e si chiede se l’applicazione sia morta o stia ancora «pensando».
Oltre all’UX ci sono anche problemi puramente tecnici:
- timeout di ChatGPT, di Vercel, dei proxy;
- impossibilità di inviare progresso, risultati parziali, ecc.;
- impossibilità di gestire correttamente interruzioni di connessione e ripristino.
Da qui la soluzione naturale: passare da un’unica grande risposta a un flusso di piccoli chunk, che il server può inviare man mano che sono pronti.
Questi chunk possono essere:
- eventi (job.progress, job.completed) — questo riguarda SSE;
- frammenti di un singolo payload grande (testo del report, righe NDJSON con i regali) — questo riguarda HTTP-stream.
3. SSE (Server‑Sent Events): sottoscrizione agli eventi
Partiamo da SSE, perché è in molti aspetti «più vicino» a MCP: lo stesso MCP sopra HTTP utilizza una connessione SSE per inviare eventi dal server al client.
Il modello SSE in parole semplici
SSE è un protocollo sopra il normale HTTP:
- il client apre una richiesta GET verso un endpoint che risponde con Content-Type: text/event-stream;
- il server non chiude la connessione, ma scrive periodicamente righe del tipo:
event: job.progress
data: {"jobId":"123","percent":40}
event: job.completed
data: {"jobId":"123","resultCount":12}
- lato browser si usa EventSource, che:
- gestisce da solo i riavvii della connessione;
- parsa il formato event: + data: + doppio a capo;
- chiama gli handler onmessage / addEventListener("job.progress", ...).
Punto chiave: il canale è monodirezionale. Solo il server invia eventi al client. Il client non invia alcun dato attraverso questa connessione.
Per le ChatGPT Apps questo modello è ottimo quando il widget vuole semplicemente «sottoscriversi» agli eventi tramite jobId e reagire al progresso e al completamento del task.
Mini‑esempio di endpoint SSE in Next.js 16
Supponiamo di avere un route handler per gli eventi di avanzamento di un Job:
app/api/gift-jobs/[jobId]/events/route.ts
import { NextRequest } from "next/server";
export async function GET(req: NextRequest, { params }: { params: { jobId: string } }) {
const jobId = params.jobId;
const stream = new ReadableStream({
start(controller) {
// Utility per inviare un evento SSE
const send = (event: string, data: unknown) => {
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
controller.enqueue(new TextEncoder().encode(payload));
};
send("job.started", { jobId });
let percent = 0;
const interval = setInterval(() => {
percent += 20;
if (percent >= 100) {
send("job.completed", { jobId, totalGifts: 10 });
clearInterval(interval);
controller.close();
} else {
send("job.progress", { jobId, percent });
}
}, 1000);
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream", // qui impostiamo l'SSE
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
Questa è una simulazione didattica: la percentuale cresce ogni secondo e alla fine arriva job.completed. Più avanti sostituirete questo timer con eventi reali del worker/della coda, ma lo schema rimarrà lo stesso.
Client: sottoscrizione a SSE nel widget GiftGenius
All’interno del widget React possiamo sottoscriverci a questo flusso quando abbiamo un jobId. Ricordo che le API del widget funzionano nella sandbox di ChatGPT, ma EventSource è disponibile come in un normale browser.
import { useEffect, useState } from "react";
export function GiftJobProgress({ jobId }: { jobId: string }) {
const [percent, setPercent] = useState(0);
useEffect(() => {
const url = `/api/gift-jobs/${jobId}/events`;
const es = new EventSource(url);
es.addEventListener("job.progress", (event) => {
const data = JSON.parse((event as MessageEvent).data);
setPercent(data.percent);
});
es.addEventListener("job.completed", () => {
setPercent(100);
es.close();
});
es.onerror = () => {
// qui si può mostrare "Problemi di connessione, tentiamo la riconnessione"
};
return () => es.close();
}, [jobId]);
return <div>Avanzamento della selezione dei regali: {percent}%</div>;
}
Ora potete collegarlo a uno strumento MCP. Lo strumento start_gift_job restituisce jobId e nel ToolOutput del vostro widget renderizzate semplicemente GiftJobProgress.
Riconnessione automatica e Last‑Event‑ID
EventSource per standard tenta di riconnettersi automaticamente se la connessione si interrompe. Il server può usare il campo standard SSE id: negli eventi e il client — l’header Last-Event-ID — per recuperare gli eventi mancanti dopo la riconnessione.
Per un semplice GiftGenius potete non implementare né id:, né un identificatore separato degli eventi e tollerare un piccolo «buco» nel progresso durante la riconnessione. Ma in produzione, soprattutto sotto carico, vi servirà:
- aggiungere il campo standard id: a ogni evento SSE, così che il client possa inviare Last-Event-ID alla riconnessione;
- introdurre un event_id applicativo nel payload dell’evento e basarsi su di esso per una gestione idempotente lato client/backend.
Questo si integra direttamente con l’idempotenza: anche se lo stesso job.progress arriva due volte, l’handler, vedendo un event_id già noto, non eseguirà di nuovo effetti collaterali.
In definitiva, SSE ci offre una comoda sottoscrizione agli eventi attorno a jobId con autoriconnessione e controllo dei duplicati tramite identificatori degli eventi. Ora vediamo il secondo tipo di flussi — quando abbiamo una sola richiesta ma una risposta molto grande che vogliamo fornire a pezzi.
4. HTTP‑streaming: rispondere gradualmente a una singola richiesta
Se SSE è «sottoscrizione a eventi indipendenti», l’HTTP‑streaming è «una richiesta, una risposta, ma la risposta è spalmata nel tempo e arriva a chunk».
È proprio il meccanismo che vedete quando usate le OpenAI API con stream : true: il server invia chunk JSON (spesso in formato SSE, ma la logica è «una richiesta ↔ flusso di risposta parziale»), e il client li assembla nel testo finale.
Nei vostri API potete fare lo stesso per:
- lunghi report testuali (ad esempio, la spiegazione della logica dei regali scelti),
- elenchi lunghi di regali (trasmetterli a pezzi, anziché far attendere l’utente).
Endpoint HTTP‑stream più semplice in Next.js
Supponiamo di dover generare la «spiegazione» del risultato della selezione, in cui l’LLM scrive un testo lungo. Vogliamo trasmetterlo al widget man mano che viene generato.
app/api/gift-report/route.ts
import { NextRequest } from "next/server";
export async function POST(req: NextRequest) {
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
controller.enqueue(encoder.encode("Iniziamo l'analisi...\n"));
// Qui potrebbe esserci una reale generazione LLM a chunk
for (const line of ["Raccogliamo le preferenze...\n", "Calcoliamo il budget...\n", "Raccomandazioni finali...\n"]) {
await new Promise((r) => setTimeout(r, 1000));
controller.enqueue(encoder.encode(line));
}
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Transfer-Encoding": "chunked", // Qui indichiamo che è HTTP/stream
},
});
}
Tecnicamente Next gestisce da sé la codifica chunked; per voi è importante solo restituire un ReadableStream.
Lettura dello stream HTTP nel widget tramite fetch
Dal lato client (dentro il widget) potete leggere il flusso così:
async function fetchReport(setText: (s: string) => void) {
const res = await fetch("/api/gift-report", { method: "POST" });
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let acc = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
acc += decoder.decode(value, { stream: true });
setText(acc); // aggiorniamo la UI man mano che arrivano i dati
}
}
E il componente wrapper:
import { useState } from "react";
export function GiftReport() {
const [text, setText] = useState("");
return (
<div>
<button onClick={() => fetchReport(setText)}>Genera report</button>
<pre style={{ whiteSpace: "pre-wrap" }}>{text}</pre>
</div>
);
}
È un pattern classico: una richiesta POST a /api/gift-report, in risposta — un flusso di testo che visualizzate progressivamente.
Trasmettere in streaming JSON, non testo
Spesso vorrete trasmettere non stringhe, ma oggetti JSON. Il formato più popolare è NDJSON (Newline‑delimited JSON): ogni evento è una riga JSON e termina con il simbolo \n.
Esempio lato server:
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
for (let i = 0; i < 5; i++) {
const chunk = { type: "gift", index: i, name: `Regalo #${i}` };
controller.enqueue(encoder.encode(JSON.stringify(chunk) + "\n"));
await new Promise((r) => setTimeout(r, 500));
}
controller.close();
},
});
Il client legge con TextDecoder, divide per \n e fa il parse dei singoli oggetti JSON.
5. SSE vs HTTP‑stream: qual è la differenza e come scegliere
A questo punto il quadro dovrebbe essere intuitivo, ma fissiamolo comunque sotto forma di una piccola tabella.
| Caratteristica | SSE (Server‑Sent Events) | HTTP‑stream (chunked) |
|---|---|---|
| Iniziatore | Il client fa una GET e si sottoscrive | Il client effettua una richiesta (GET/POST), il server trasmette la risposta |
| Direzione | Solo server → client | Risposta del server a una richiesta specifica |
| Semantica | Sottoscrizione a un flusso di eventi (pub/sub) | Risposta parziale a una singola richiesta |
| Protocollo integrato | Sì (event:, data:, id: ecc.) | No, scegliete voi il formato (stringhe, NDJSON, JSON) |
| API client | EventSource | fetch + ReadableStream / response.body |
| Supporto riconnessione | Integrato (EventSource, Last-Event-ID) | Da implementare a mano |
| Casi tipici | Progresso, stati, notifiche per jobId | Streaming di testo, grandi risposte JSON, output LLM |
Semplificando in «regole pratiche» (con cautela, senza fanatismi):
- avete un job e molti eventi attorno ad esso → SSE;
- un singolo tool‑call restituisce un risultato grande che volete mostrare a pezzi → HTTP‑stream.
Per GiftGenius questo significa: SSE — per la barra di avanzamento in tempo reale e gli stati della selezione; HTTP‑stream — per un lungo riepilogo testuale o per il caricamento progressivo di un elenco lungo di regali.
6. Come si integra con MCP e GiftGenius
Ricordiamo il nostro schema all’inizio della lezione: modello ↔ MCP ↔ widget ↔ backend. Abbiamo già visto i flussi al livello widget ↔ backend, ora torniamo un passo indietro e distinguiamo chiaramente dove c’è MCP e dove c’è «semplice HTTP».
MCP definisce come ChatGPT (come client MCP) comunica con il vostro server MCP. A tal fine c’è un trasporto, in cui:
- ChatGPT apre una connessione SSE /sse e riceve tramite essa i messaggi MCP (risposte, notifiche, eventi);
- ChatGPT invia richieste MCP (call_tool, list_tools ecc.) a /messages, di solito come POST con JSON‑RPC.
Questo livello l’avete già affrontato quando avete collegato GiftGenius a ChatGPT.
Ora, quando aggiungiamo task asincroni e flussi UX nel widget, compaiono due varianti architetturali.
Prima variante — «MCP puro»: il server MCP genera da sé gli eventi job.progress e job.completed; ChatGPT li riceve tramite MCP‑SSE; poi il modello richiama da sé il vostro widget con un contesto aggiornato, e il widget renderizza il progresso senza parlare direttamente con il backend. È il percorso più «canonico» degli MCP‑events.
Seconda variante — ibrida: lo strumento MCP start_gift_job crea il task e restituisce un jobId; il widget riceve il jobId e poi comunica da solo con il backend via HTTP, sottoscrivendosi all’endpoint SSE /api/gift-jobs/{jobId}/events e, se necessario, richiedendo lo stream HTTP del report. Dal lato MCP, in questo caso, non accade nulla di particolare.
Nel corso seguiamo la via ibrida: si integra meglio con App Router/Next ed è più semplice per il debug locale. Più avanti potrete passare alle «notifiche MCP pure», quando vi sentirete pronti.
7. Riconnessione, timeout e altre realtà della rete
Finora è suonato tutto ideale: apriamo SSE o uno stream, tutto scorre, gli eventi arrivano, l’UX brilla. Nella vita reale la rete ama interrompere le connessioni in momenti inattesi e l’infrastruttura — imporre timeout.
Cosa può andare storto
Con SSE e HTTP-stream prima o poi vi imbatterete in:
- idle timeout sui proxy: «se sulla connessione non passa nulla per N secondi — chiudiamo»;
- riavvio del vostro backend (deploy, incidente);
- rete instabile lato utente (soprattutto su mobile).
È normale; è importante essere pronti, non sperare che «vada tutto liscio».
Strategia per SSE
SSE ha molti vantaggi proprio in quest’area:
- EventSource si riconnette da solo con un certo backoff;
- avete id: e Last-Event-ID per recuperare gli eventi.
Set minimo di buone pratiche:
- Sul server inviare periodicamente qualcosa, tipo un heartbeat, affinché la connessione non sia considerata completamente idle. Può essere un evento dedicato event: ping o semplicemente un commento : keep-alive.
- Sul client, in onerror, mostrare all’utente uno stato chiaro del tipo «Problemi di connessione, tentiamo la riconnessione…», senza rompere l’intero widget.
- Alla riconnessione, se usate id:, inviare dal server solo i nuovi eventi successivi a quell’ID. Per GiftGenius potete iniziare anche senza id: e semplicemente «ricostruire» lo stato sull’ultimo job.progress/job.completed ricevuto.
Strategia per HTTP‑stream
Un HTTP‑stream è una singola richiesta, quindi in caso di interruzione, di fatto, dovete ricominciare da capo:
- se trasmettete in streaming un report testuale, potete semplicemente dire all’utente «Impossibile ottenere il report completo, riprovate» e ricominciare;
- se trasmettete in streaming dati strutturati (NDJSON), potete pensare a un meccanismo di resume: ad esempio, passare nella richiesta un offset o un cursor da cui riprendere.
Per iniziare non complicatevi: se lo stream della risposta si interrompe prima della fine — mostrate ciò che è arrivato e un pulsante «Continua la generazione del report», che invierà una nuova richiesta.
L’importante è non lasciare l’utente in uno stato di «attesa infinita».
8. Applichiamo a GiftGenius: scenario dall’inizio alla fine
Ora mettiamo insieme tutto ciò che abbiamo discusso su SSE, HTTP‑stream e le due varianti architetturali con MCP, in uno scenario reale di GiftGenius — dalla richiesta dell’utente al report pronto.
L’utente in ChatGPT scrive: «Scegli un regalo per un appassionato di giochi da tavolo, budget fino a 100 dollari». Il modello decide di chiamare GiftGenius. L’app/agente esegue il tool‑call start_gift_job sul vostro server MCP. Il server:
- registra il job nel DB;
- lo invia a una coda interna (i dettagli su code e worker — nella prossima lezione; per ora assumiamo che «qualcuno» lo esegua);
- restituisce in modo sincrono un jobId in risposta al tool‑call.
Il widget GiftGenius riceve un ToolOutput con jobId e renderizza il componente:
function GiftGeniusRoot({ jobId }: { jobId: string }) {
return (
<div>
<h2>Stiamo cercando i regali perfetti...</h2>
<GiftJobProgress jobId={jobId} />
<GiftReport />
</div>
);
}
Il componente GiftJobProgress si sottoscrive a SSE /api/gift-jobs/{jobId}/events e disegna l’avanzamento. Ogni job.progress aggiorna la percentuale, job.completed — imposta 100% e, eventualmente, abilita il pulsante «Mostra il report dettagliato».
Il componente GiftReport, al click sul pulsante, invia una POST a /api/gift-report (passandogli il jobId) e visualizza gradualmente il report testuale mentre il server invia i chunk dello stream HTTP.
In caso di interruzione della connessione SSE, il widget mostra un avviso soft e EventSource tenta la riconnessione. Se ci sono problemi con lo stream del report, l’utente vede la parte già arrivata e un pulsante «Continua la generazione» o «Riprova».
Dal punto di vista di ChatGPT e MCP:
- MCP vede il tool‑call start_gift_job e, forse, poi notifiche sugli stati del job;
- l’UX attorno ai flussi è realizzata principalmente a livello HTTP tra il widget e il vostro backend.
9. Errori tipici nel lavoro con SSE e HTTP‑stream
Errore n. 1: considerare SSE e HTTP‑stream «la stessa cosa».
Sì, in basso condividono HTTP e risposte chunked, ma la semantica è molto diversa. SSE è la sottoscrizione a eventi indipendenti, che possono arrivare in qualunque momento e di cui il client non sa in anticipo. L’HTTP‑stream è una singola risposta specifica, distribuita nel tempo. Se provate a implementare la sottoscrizione a molti jobId tramite un unico HTTP‑stream, dovrete inventare un vostro protocollo sopra i byte, di fatto ricostruendo metà di SSE.
Errore n. 2: ignorare l’autoriconnessione di SSE e non pensare all’idempotenza.
Molti scrivono un server SSE «semplice»: inviano data: ... e non aggiungono il id: standard (per Last-Event-ID) né un event_id applicativo nel corpo dell’evento. Poi, alla prima interruzione e riconnessione, iniziano a proliferare duplicati di eventi. Senza un event_id ben pensato e la logica «ho già visto questo evento», l’handler lato client rischia di aggiornare due volte lo stato, mostrare due volte lo stesso job.completed o, peggio, addebitare due volte/accreditare bonus due volte.
Errore n. 3: inviare ogni minimo aggiornamento del worker come evento SSE separato.
Se inviate l’avanzamento del task via SSE ogni millisecondo, probabilmente ucciderete rete e client, invece di far felice l’utente con un’animazione fluida. Molto più sensato è aggregare gli update e inviare il progresso, per esempio, ogni 200–500 ms o al cambio di fase del processo. Il tema del throttling e del backpressure lo discuteremo ancora, ma già ora conviene pensare alla frequenza degli eventi.
Errore n. 4: costruire protocolli complessi sopra l’HTTP‑stream senza un formato esplicito.
Antipattern tipico: trasmettere JSON senza separatori e cercare di «indovinare» dove finisce un oggetto e inizia il successivo. Oppure mescolare nello stesso flusso testo e JSON. La via migliore è scegliere un formato semplice e chiaro: testo per righe, oppure NDJSON (un oggetto JSON per riga), oppure separatori espliciti. Così il parser lato client resterà comprensibile.
Errore n. 5: dimenticare i timeout e gli stream «infiniti».
A volte si implementano endpoint SSE che non inviano nulla per 5–10 minuti, e poi ci si stupisce che le connessioni si interrompano lungo il percorso dall’utente al server (bilanciatori, API gateway, proxy aziendali). Heartbeat regolari (eventi o commenti) consentono di mantenere viva la connessione e di rilevare per tempo le interruzioni. E gli HTTP‑stream non devono trasformarsi in risposte infinite — per le sottoscrizioni permanenti c’è SSE.
Errore n. 6: cercare di fare con l’HTTP‑stream un pub/sub complesso invece di normali eventi.
A volte sorge la tentazione: «Facciamo un unico stream e ci inviamo progresso, risultati parziali e log casuali». Di conseguenza, lato client apparirà un multiplexer complesso che analizza ogni chunk e decide a quale jobId appartiene. Nella maggior parte dei casi è più semplice e affidabile usare SSE con eventi come job.progress, job.completed e un canale separato per job, piuttosto che inventare un mega‑protocollo artigianale sopra l’HTTP‑stream.
Errore n. 7: legare rigidamente l’UX al fatto che il flusso «non cade mai».
Qualsiasi flusso prima o poi si interromperà. Se il vostro widget rimane con una barra di avanzamento eternamente animata e senza alternative — l’UX è percepita come «rotta». Anche un semplice messaggio «Sembra che la connessione si sia interrotta. Provate a riavviare la selezione dei regali» con un pulsante «Riprova» è molto meglio del silenzio.
GO TO FULL VERSION