Whetstone.
StreamingCustom writer events and SSE into the browser
Module 6, Lesson 225 min

Custom writer events and SSE into the browser

Custom events are how you surface domain-specific progress. Inside a node, config.writer emits an event into the custom stream.

Emitting custom events from a node
const rollNode = (state, config) => {
  config.writer?.({ type: "dice", weights: state.weights });
  const face = rollDice(state.weights, state.random);
  config.writer?.({ type: "roll", face });
  return new Command({ update: { face }, goto: `${face}Node` });
};

That is how the dice weights and the roll outcome render distinctly from the token stream: they are custom events, not messages tokens.

When you want every internal event, model start, tool start, token, node end, rather than node-level deltas, streamEvents is the fine-grained firehose.

streamEvents for a fine-grained firehose
for await (const ev of await graph.streamEvents(input, { version: "v3" })) {
  // ev.method, ev.params.data - filter to what the panel needs
}

SSE, Server-Sent Events, is a one-way stream over HTTP: the server holds the connection open and pushes data: lines. Your route iterates graph.stream(…) and writes each chunk as an SSE message; the browser’s EventSource receives them and updates the panel.

Practice

Try it yourself

Recall

Why combined stream modes arrive as tuples

The alternative to compare against is a single merged stream with no tags on it.

Why do combined stream modes arrive as [mode, chunk] tuples rather than as one flat merged stream?

Reveal answer

Different stream modes carry structurally different payloads -- a values chunk is full state, an updates chunk is a delta, a custom chunk is whatever shape you emitted from config.writer. If they were merged into one undifferentiated stream, your consumer code couldn't tell which kind of chunk it just received or how to interpret it. Tagging each chunk with its mode lets you branch cleanly (as in the if (mode === "custom") example).

Recall

Why dice events stay off the token channel

The dice weights and roll outcome go out as custom events through config.writer. Answer in terms of what the messages channel is for.

Why not put dice progress info into the messages stream?

Reveal answer

The messages stream is specifically for LLM-generated tokens, meant for typewriter-style rendering of model output. Dice weights and roll outcomes are structured domain data, not LLM tokens -- mixing them into the messages channel would force the panel to parse LLM output for embedded metadata instead of just rendering distinct, clearly-typed custom events.

Do

Stream the dice graph into a live panel

Build an SSE endpoint that runs the module 3 impulse graph and streams its progress into a browser panel.

  • In ~/dev/groundschool, add a route that runs the m3 impulse graph via graph.stream with combined ["updates", "custom"] stream modes.
  • Emit custom writer events for the dice weights (before rolling) and the roll outcome (after rolling), following cockpit's ops.stream.tsx SSE pattern.
  • Build a small panel that lights up each node as its update chunk arrives.
  • Render dice weights and roll outcome as distinct UI elements from any token stream.
  • Optionally expose the route through Reception and confirm it still works over that path.
Done whenThe browser panel shows each node lighting up as the graph runs, and the custom dice-weight/roll-outcome events render visibly distinct from any token stream.
Check

Live panel shows node-by-node progress

Confirm the streaming panel behaves as expected.

You should see

Each node visibly lights up in the panel as the graph executes it, and custom writer events (dice weights, roll outcome) are rendered distinctly from the token stream.

Sign in to track your progress →