The worst part of a chat UI is the blank screen while the model thinks. Fix: stream the tokens as they're generated, not the whole reply at the end. In PHP that's Server-Sent Events (SSE) - one persistent response that keeps pushing data.
NanoAgent's Agent::stream() hands you each token through a callback. Your job is just to format it as SSE and flush.
Everything below only runs when the request is asking for a stream (?stream=1&message=...). The four headers are what turn a normal PHP response into a Server-Sent Events stream the browser will keep open and read incrementally.
require_once __DIR__ . '/../NanoAgent/auto.php';
use NanoAgent\Agent;
if (isset($_GET['stream']) && !empty($_GET['message'])) {
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('Connection: keep-alive');
header('X-Accel-Buffering: no'); // critical under Nginx
$userMessage = trim($_GET['message']);
$configFile = __DIR__ . '/../NanoAgent/config.php';
$config = file_exists($configFile) ? require $configFile : [];
$agent = new Agent(
llm: [
'provider' => $config['provider'] ?? 'groq',
'model' => $config['model'] ?? 'llama-3.3-70b-versatile',
'api_key' => $config['api_key'] ?? ''
],
systemPrompt: "You are a helpful and concise streaming assistant."
);
stream() calls your closure once for every token the model generates. Each call formats the token as an SSE data: line and forces PHP to send it immediately instead of buffering it.
$agent->stream($userMessage, function ($token) {
echo "data: " . json_encode(['token' => $token]) . "\n\n";
ob_flush();
flush();
});
Once the model is done, send a sentinel value the client can watch for, then close out the request.
echo "data: [DONE]\n\n";
ob_flush();
flush();
exit;
}
Two things are non-negotiable here:
ob_flush() + flush() after every token.X-Accel-Buffering: no.
A plain EventSource - no library, no WebSocket:
const es = new EventSource(`/streaming.php?stream=1&message=${encodeURIComponent(msg)}`);
es.onmessage = (e) => {
if (e.data === '[DONE]') { es.close(); return; }
const { token } = JSON.parse(e.data);
answerEl.textContent += token;
};
For one-way token flow (model → browser), SSE is simpler: it's plain HTTP, auto-reconnects, and works through proxies that choke on WebSockets. You only need a real bidirectional socket if the client has to interrupt mid-stream - which is a separate feature.
stream() turns "wait 8 seconds for a wall of text" into "watch it type." The entire change from a blocking chat() is one callback + two flush() calls + the SSE headers. Everything else in your app stays the same.
Part of the NanoAgent examples series. Landing + demos.