Timothy July 9, 2026 ~4 min read

Timothy Part 5: The First Web Chat Interface

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 lint and the equivalent web build are green.
  • docker compose up gives 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.

// end of article — process exited with code 0

// share:Twitter / XLinkedIn

// comments

loading...