AI Chat
An assistant that reads and modifies your notes — streamed token by token.
You will learn
- What Server-Sent Events (SSE) are and how they differ from HTTP
- Stream events from ZealPHP with $response->sse()
- How tool calls let an AI agent modify your data
- The architecture: PHP spawns Python, Python streams SSE, browser renders live
1. Overview — what you’re building
Your notes app works, but the user does everything manually. By the end of this lesson, they can talk to an assistant that reads their notes, searches them, and creates or deletes them on command. The reply streams token by token, like ChatGPT, so a slow model never feels frozen.
Chat, ChatHistory)
ship with the framework in vendor/. You’ll create:
api/learn/chat.php— SSE streaming endpointapi/learn/chat_status.php— reports whether real AI is enabledtemplate/components/_chat_widget.php— the chat UI
OPENAI_API_KEY to switch to a real model.
Five pieces, in build order:
- The PHP-to-Python bridge — why we spawn a subprocess instead of calling OpenAI from PHP, and how the wire protocol works.
- The streaming proxy route — one PHP route that opens an SSE connection and forwards whatever the Python process emits.
- The chat UI — a form + an
EventSourceclient that appends tokens as they arrive. - Tool calls — the AI doesn’t just talk; it calls your existing notes API. Same endpoints the UI uses.
- The live event log — a side panel that shows every SSE event in real time, so you can see the protocol working.
The widget at step 6 is the finished product; the sections in between are the build.
2. Component extraction — the chat widget
Like the Notes lesson, the entire chat UI — left-side notes panel, center chat box, right-side event log — is extracted into a reusable partial that the lesson page and the standalone popup both render:
// template/components/_chat_widget.php
<?php
$user ??= null;
if (!$user) return;
?>
<section class="chat">
<div><h3 class="chat-h">Your notes</h3>
<div id="notes-list" class="notes-list" hx-get="/api/learn/notes" hx-trigger="load">…</div></div>
<div><div id="learn-chat" class="chat-box">…</div>
<div class="event-log-wrap"><div id="ws-log" class="event-log"></div></div></div>
</section>
Same as Notes, two consumers: this lesson page calls
App::render('/components/_chat_widget', ['user' => $user]) at
step 6; the standalone shell at
/demo/view/chat/widget renders the same partial inside _demo_shell.php.
The widget output is identical — #learn-chat and #ws-log ids stay
stable so /js/learn.js wires up SSE + the event-log identically in both contexts.
3. Server-Sent Events — what is it?
Regular HTTP is like texting: you send a message, get a reply, conversation over. Server-Sent Events is like calling someone and saying “read me the news”. They talk continuously, you listen. You can’t interrupt (that would be WebSocket). But for streaming AI tokens, you don’t need to — you just need to listen.
On the wire, SSE looks like this:
event: token
data: {"token":"Hello"}
event: token
data: {"token":" world"}
event: done
data: {"done":true}
Each event is two lines (event: + data:) followed by a blank line. The browser receives them one by one as they arrive.
ZealPHP makes SSE a one-liner. The $response->sse() helper sets the right
headers and gives you an $emit callback:
$response->sse(function($emit) {
$emit(json_encode(['token' => 'Hello']), 'token');
usleep(100000);
$emit(json_encode(['token' => ' world']), 'token');
$emit(json_encode(['done' => true]), 'done');
});
4. Python agent bridge
The OpenAI Agents SDK is Python-only. It handles conversation memory, tool dispatch, and token streaming — hundreds of lines of logic ZealPHP doesn’t want to reimplement. So PHP stays thin and Python does the AI work. The PHP side spawns a Python process per chat request, forwards the prompt on stdin, and reads SSE-formatted events from the subprocess’s stdout.
This rides ZealPHP’s coroutine-safe shell-out: the spawn yields to the event loop instead
of blocking the worker, the child writes a stream of bytes back through a pipe, and PHP forwards
each chunk to the client as it arrives. The protocol is plain text — you can cat
the python script’s output and read it.
5. Streaming + tool calls — the architecture
sequenceDiagram
participant B as Browser
participant PHP as ZealPHP
participant PY as Python Agent
participant AI as OpenAI API
participant API as Notes API
B->>PHP: POST /api/learn/chat
PHP->>PY: spawn (coroutine-safe shell-out)
PY->>AI: Runner.run_streamed()
AI-->>PY: token deltas
PY-->>PHP: SSE: event: token
PHP-->>B: SSE: token (streamed live)
AI->>PY: tool_call: create_note
PY->>API: POST /api/learn/notes
API-->>PY: {"id": 42}
Note over API: WS::broadcast()
API-->>B: WebSocket: note_changed
PY-->>PHP: SSE: tool_done
PHP-->>B: SSE: tool_done + notes_changed
The AI doesn’t just generate text. It can call functions that interact
with your data. The Python agent defines six tools: list_notes, read_note,
search_notes, create_note, update_note, delete_note.
When the model decides to use a tool, the agent emits a tool_call SSE event; the
result comes back as a tool_done event. The browser shows an expandable card with
the tool name, arguments, and result — you see the AI’s reasoning process in real time.
6. The cross-tab loop — how AI tool calls reach your Notes tab
Here’s the question this lesson has been quietly setting up: when the AI calls
create_note, why does the notes list on the left side of the chat
update in real time? And why does a separate /learn/notes tab open in another
window also update without the user touching anything?
The answer is the same broadcaster pattern from the previous lesson — applied to a per-user channel instead of a per-room one. Four pieces, in order along the call path:
1. The Notes API broadcasts on every mutation
Every endpoint that changes notes (POST create, PATCH update, DELETE) ends with one extra
line: a WS::broadcast() call scoped to the note’s owner. The agent’s
create_note tool call ends up at this same endpoint — there’s no
special path for AI-driven writes:
// api/learn/notes.php — POST handler, after the SQL insert
$id = Notes::create($db, $u['user_id'], $title, $body);
WS::broadcast($u['user_id'], [
'type' => 'note_changed',
'op' => 'create',
'id' => $id,
]);
2. The broadcaster filters by user_id (not room)
WS::broadcast() is the same shape as
Demo::ws_session_counter_broadcast() from the previous lesson — iterate the per-fd
Store table, push to every fd whose stored key matches:
// src/Learn/WS.php
public static function broadcast(int $userId, array $payload): void {
$server = App::getServer();
$table = Store::table('learn_ws_clients');
$json = json_encode($payload);
foreach ($table as $fd => $row) {
if ((int)$row['user_id'] === $userId) {
$server->push((int)$fd, $json);
}
}
}
The learn_ws_clients table got populated when each tab connected to
/ws/learn in onOpen — same pattern as the room table in the
WebSocket lesson, just keyed by the logged-in user’s session['user_id']
instead of a query-string room name.
3. The client handler refreshes the notes list
Every tab that’s on a page containing a #notes-list opens a WebSocket
to /ws/learn on first paint. When a note_changed arrives, the
handler in /js/learn.js does the cheapest thing: ask htmx to refetch the list:
// public/js/learn.js — inside the /ws/learn onmessage
if (msg.type === 'note_changed') {
htmx.ajax('GET', '/api/learn/notes', {
target: '#notes-list',
swap: 'innerHTML',
});
}
The fetch hits the same endpoint htmx would use on a manual reload — server renders the
list, htmx swaps it into #notes-list. For the special case of a
delete, the handler removes the single card directly (smoother animation, no
full re-render). For create, it adds a brief amber glow so you can see which
note is new.
4. The pay-off: AI tool call == user action
The Notes API doesn’t know whether the POST came from a human submitting a form or
from the Python agent acting on the model’s decision. It treats both the same
way — same SQL insert, same broadcast call. So the moment the agent calls
create_note:
- The chat tab’s notes list refreshes (its own WS connection received the broadcast).
- Your separate
/learn/notestab refreshes (also subscribed to/ws/learn, filtered by the sameuser_id). - The standalone notes popup refreshes (same WS, same handler).
- None of them got a special message about “the AI did a thing” — they got the exact same
note_changedevent that fires when you create a note yourself.
This is why the AI integration adds zero complexity to the cross-tab sync code: the tool call is a user action, just one initiated by a different process. The Notes → WebSocket → client-handler chain doesn’t need a single line of special-casing.
7. Try it — the live chat widget
What you built — and the streaming shape underneath it
Look at what's running on this page right now:
- A chat box that posts to
/api/learn/chatand reads an SSE stream back. - A Python subprocess that the PHP route spawned, currently sitting on a pipe waiting for the next prompt.
- A notes list on the left that updates the moment the AI calls
create_note— the tool call hits/api/learn/noteswhich broadcasts anote_changedevent on the same per-user channel every other tab is subscribed to. The full chain is walked through at step 6. - An event log that visualizes every SSE and WS message as it arrives.
The whole thing is < 200 lines of PHP plus the Python agent script. No queue worker, no
Redis, no message broker, no separate Node service for streaming. One php app.php
process. That's the headline.
Bonus — App::renderStream()
SSE is one form of streaming. ZealPHP also supports streaming HTML — sending chunks of a page as they're generated, rather than waiting for the entire page to render.
// A streaming template
return function($items) {
yield "<section>";
foreach ($items as $item) {
yield "<div>{$item->name}</div>";
}
yield "</section>";
};
App::renderStream() returns a Generator. Each yield is flushed to the
browser immediately. For AI responses, database-heavy pages, or any slow render, this means the
user sees content arriving progressively instead of staring at a blank screen.
Why use SSE instead of returning JSON after the full response is ready?
Why pipe Python instead of calling OpenAI from PHP?
The OpenAI Agents SDK (Python) handles tool dispatch, conversation memory, and streaming out of the box. Replicating that in PHP would be hundreds of lines. By spawning a subprocess, ZealPHP stays thin — it's a streaming proxy, not an AI framework. This pattern lets you swap the agent (different model, different tools) without touching PHP.
Key Takeaways
- SSE streams events from server to browser — perfect for AI token streaming
$response->sse($emit)handles headers and formatting automatically- Tool calls let AI agents interact with your data — shown as expandable cards
App::renderStream()streams HTML chunks for progressive page rendering