Lab 4 — Streaming and SSE
Time: 30 minutes · Prerequisites: Lab 1
Why this matters
Everything in this lab is in service of Marco not sending a bad reply.
The draft route writes to a customer, and the customer might be the person whose daughter went to urgent care. Tone rules that look fussy on paper — lead with the resolution, one apology, never promise a refund "today" — are each there because a version of that message went out and made things worse. The 5–7 day language exists because agents kept promising same-day refunds and customers kept escalating on day two.
Streaming matters for a narrower reason: an agent will not wait fifteen seconds staring at a spinner during a December shift with 11,000 tickets in the queue. They will alt-tab, and the draft will sit unread. Perceived latency is the difference between a tool that gets used and one that gets abandoned.
The two production details in Steps 4 and 6 — proxy buffering and client disconnects — are how this feature works in your dev environment and fails in theirs.
Objectives
- Stream a response and handle the event types you actually care about
- Get
usageout of a stream (it isn't there until the end) - Handle errors that occur after the HTTP status is already 200
- Abort an upstream stream when the client disconnects
Step 1 — watch tokens arrive
curl -N -s localhost:8787/v1/draft -H 'content-type: application/json' -d '{
"message":"My Trail Club membership renewed but I cancelled in June. I want the $89 back."
}'
-N disables curl's buffering. Without it you see the whole reply at once and
conclude streaming is broken — a preview of Step 4.
Step 2 — the event taxonomy
Read src/routes/draft.ts. The route emits four
event types:
| Event | Carries | A UI should |
|---|---|---|
text | reply delta | append to the message body |
thinking | summarized reasoning delta | render in a collapsed panel |
done | stop_reason, usage | stop the spinner, log cost |
error | error body | show a failure state |
Now comment out display: "summarized" in the thinking config and re-run.
Q1. The thinking events still arrive but carry empty text. Given that
thinking happens and is billed identically either way, what exactly does
display control — and why does the default ("omitted") produce a bad
first-run UX?
Step 3 — get the cost
usage is not available mid-stream. It arrives at the end:
const final = await stream.finalMessage();
send("done", { usage: summarizeUsage(final.usage) });
Q2. A teammate proposes wrapping stream.on("message", ...) in a
new Promise() to capture the final message. Why is finalMessage() the
correct tool, and what states does it handle that the hand-rolled promise
would not?
Step 4 — the production failure you will hit
Everything above works locally. Deployed behind nginx with default settings, the client receives the entire reply in one chunk after full generation.
Look at SSE_HEADERS in src/lib/sse.ts.
Q3. Which header prevents this, and why is Cache-Control: no-transform
also there? Name one other layer between your service and the browser that can
buffer a stream.
Your streaming endpoint fails upstream halfway through generating. What does the client see?
Step 5 — errors after 200
Simulate an upstream failure mid-stream. Easiest reproduction: start the server
with a valid key, begin a request, then note that src/routes/draft.ts catches
inside the ReadableStream and emits an in-band error event.
Q4. By the time generation starts, the HTTP status is already 200. Write
the one-sentence rule a client integrator must follow, and explain what breaks
if they only check res.ok.
Step 6 — hang up
c.req.raw.signal.addEventListener("abort", () => stream.abort());
Start a request and kill curl mid-generation (Ctrl-C).
Q5. Without this line, what continues to happen, and who pays for it?
Checkpoint
- Which
content_block_deltadelta types matter, and what do they mean? - How do you get
usagefrom a stream? - Why can't a mid-stream error be an HTTP error?
- What must you do on client disconnect?
Extension
Build a 40-line HTML page that consumes /v1/draft with EventSource (note:
EventSource is GET-only, so you will need fetch + a ReadableStream reader
for a POST body — discovering that is part of the exercise). Render text into
the body and thinking into a <details> element.
Answers: ../solutions/lab-4.md