In Part 4 the brain learned to speak a clean authenticated protocol over SSE and always terminate with a meta event.
The last piece of the foundation slice is code that speaks the authenticated SSE contract to the brain. It keeps the transcript state until proper session loading lands.
The surface speaks the same authenticated SSE contract as everything else.
The Stream Contract
The brain expects Authorization: Bearer ... and returns SSE frames that end with a meta object carrying session_id, provider, model and usage.
A thin streaming layer is required because the browser’s built-in EventSource cannot send custom headers and does not surface response headers as soon as they arrive.
// streaming layer
export async function chatStream(...) { ... }
signal,
});
if (!res.ok) { /* structured ChatError with sessionId if present */ }
// Read the header the moment headers arrive, before any body bytes
const headerSession = res.headers.get('X-Session-Id');
if (headerSession) onSession?.(headerSession);
const reader = res.body.getReader();
const parser = createSSEParser(onEvent);
// feed chunks...
}
The custom parser joins multi-line data: frames and skips comments and malformed JSON. It is tested with byte-at-a-time feeding to simulate real network chunking.
Errors are turned into a ChatError class carrying status, code, and optional sessionId. The client reacts to auth failures by prompting for the token.
Handling the Event Stream
The normalized events are accumulated into the transcript:
// web/src/lib/chat.ts
export function applyEvent(msg: AssistantState, ev: ChatEvent): AssistantState { ... }
Retry and incomplete events are first-class states rather than hidden. The same accumulator shape serves both live streaming and replay from the server projection. This paid off immediately when a provider dropped mid-conversation.
Streaming That Survives
If the connection drops, the error surfaces on the current turn while preserving the session id (from the X-Session-Id header or meta event) so the next message can continue the conversation on the server.
Category Per Message
Every turn carries its own task_category (the brain defaults to “coding”). This travels with the session state.
The dev server proxies /v1 to the brain. The token is supplied with each request.
The streaming layer and event accumulation are tested (byte-at-a-time parsing, full sequences).
Running It
make up # or docker compose up
# in another terminal if you want to watch
make logs
Send a message. Chunks stream in, reasoning is available separately when emitted, and the terminal meta event supplies the provider, model, and usage that end up in the cost ledger.
What Is Missing (by Design)
- No session list or history loading yet. The transcript state is held only until sessions become durable.
- No tool rendering (the events are ignored for now).
- No admin surfaces for providers.
- The in-memory 20-message buffer on the brain is explicitly temporary.
All of that is upcoming milestone work. The goal for the foundation was a working, streaming chat that exercises the full path from request through brain through gateway to a real provider and back, with every request accounted for.
Closing the First Loop
With the streaming surface in place, the foundation I set out to build in Part 2 is real, and I held it to the checklist I wrote before starting:
make build test vet lintand the equivalent web build are green.docker compose upgives a live chat against a real model: from initialized repo to this point took about 36 hours of building.- Every request produces a ledger row with tokens, latency, and cost.
- Stopping Postgres degrades the services without a single container restart; starting it again recovers them automatically.
- The failure modes (bad token, gateway unreachable, mid-stream cut) are handled without losing the conversation id.
The services are talking. The credential boundary is respected. The streaming contract is solid.
Next I start replacing the temporary pieces: real event-sourced sessions, proper history, compaction, and the ability to resume a conversation after a restart.
That is the work of the next post.