npm.io
0.1.0 • Published 3d ago

bravoh-wire

Licence
MIT
Version
0.1.0
Deps
0
Size
189 kB
Vulns
0
Weekly
0
Stars
1

bravoh-wire

Zod-typed WebSocket frames for streaming AI chat UIs — the wire protocol our production AI chat runs on.

npm license

Every streaming AI chat ends up inventing the same wire protocol: stream chunks, tool lifecycles, acks, rate limits, async job delivery. Usually as untyped JSON.parse + string comparisons, usually slightly differently on each end, usually crashing the first time the server ships a frame the client hasn't heard of.

bravoh-wire is that protocol, extracted from BRAVOH's production app (FastAPI agent backend React 19 chat UI): 29 frame types as Zod discriminated unions, a parser that never throws, and full TypeScript narrowing on frame.type.

Install

npm install bravoh-wire zod

30 seconds

import { aiChatProtocol } from "bravoh-wire"

const proto = aiChatProtocol()

ws.onmessage = (event) => {
  const msg = proto.parse(event.data) // string or parsed JSON, up to you
  if (!msg) return                    // unknown/invalid frame: logged, ignored, no crash

  switch (msg.type) {
    case "stream_chunk":
      append(msg.data?.text)          // ← fully typed, narrowed by `type`
      break
    case "tool_start":
      showTool(msg.data?.tool)
      break
    case "compose_ready":
      deliverTrack(msg.data.audio_url)
      break
  }
}

Why this exists

Three production lessons are baked in:

  1. Unknown frames must not crash. During a rolling deploy the server WILL send frame types the client doesn't know yet. parse() returns null and logs — the UI keeps running. This rule has survived every BRAVOH rollout since the protocol existed.
  2. Zod strips by default. A plain z.object() silently deletes unknown payload keys — we lost real server fields to this before making passthrough the payload convention. New server fields now survive old clients.
  3. Type the union once. One discriminated union on type gives every switch full narrowing. No as casts in the message handler, ever.

The frame set

Family Frames
Lifecycle connected pong error message_ack message_received rate_limited
Conversation conversation_created conversation_loaded conversation_cleared title_updated
Presence typing thinking message_interrupted
Streaming stream_start stream_chunk stream_end
Tool lifecycle tool_start tool_progress tool_complete tool_backgrounded tool_landed tool_failed reinvocation
Rich message agent_message (quotes, tool results, suggestions, milestones — the full turn)
Async jobs compose_ready compose_streaming compose_progress image_ready image_progress

Long-running tools background (tool_backgrounded) and later land (tool_landed); generation jobs kicked off in a turn deliver whenever they finish (compose_ready). That's how a chat turn stays snappy while a 90-second job runs.

Your own frames

The core set is a starting point, not a cage. extend() returns a new protocol with your product's frames appended:

import { z } from "zod"
import { aiChatProtocol, frame, payload } from "bravoh-wire"

const orderShipped = frame("order_shipped", payload({
  order_id: z.string(),
  eta: z.string().optional(),
}))

const proto = aiChatProtocol().extend([orderShipped])
// proto.parse now handles all 29 core frames + order_shipped, fully typed

We eat this dog food: BRAVOH's gamification and desktop-studio frames ship in this package as bravohExtras — six real product frames appended exactly this way (import { bravohExtras } from "bravoh-wire").

Or skip our frame set entirely and build from scratch:

import { createProtocol, frame, payload, silentLogger } from "bravoh-wire"

const proto = createProtocol([
  frame("ping"),
  frame("tick", payload({ seq: z.number() })),
], { logger: silentLogger }) // logger is swappable — pipe warnings anywhere

Honest scope

  • Client-side validation of server→client frames. Outgoing client→server messages are yours to type — we don't runtime-validate what we send, only what we receive.
  • Not a transport. Bring your own WebSocket/reconnect/backoff. This is the layer between event.data and your typed handler.
  • The frame set mirrors our production protocol — field names are exactly what our backend sends (snake_case and all). Generalizing further is what createProtocol is for.
  • Zod v3.25+ and v4 are both supported (peer dependency).

Contributing

  • npm test / npm run typecheck / npm run build — or ./gates.sh for the full local gate.
  • Good first issues are labeled — protocol docs, frame diagrams, and framework adapters (React hook, Vue composable) are all open.
  • New frames need: schema + test + a row in the frame table above.

License

MIT BRAVOH — part of BRAVOH open source, alongside bravoh-daw.

Keywords