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.
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.
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.
Try it yourself
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).
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.
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.
Live panel shows node-by-node progress
Confirm the streaming panel behaves as expected.
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.