Skip to main content

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 usage out 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:

EventCarriesA UI should
textreply deltaappend to the message body
thinkingsummarized reasoning deltarender in a collapsed panel
donestop_reason, usagestop the spinner, log cost
errorerror bodyshow 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.

Check

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_delta delta types matter, and what do they mean?
  • How do you get usage from 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