bravoh-wire
Zod-typed WebSocket frames for streaming AI chat UIs — the wire protocol our production AI chat runs on.
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:
- Unknown frames must not crash. During a rolling deploy the server WILL send frame types the client doesn't know yet.
parse()returnsnulland logs — the UI keeps running. This rule has survived every BRAVOH rollout since the protocol existed. - Zod strips by default. A plain
z.object()silently deletes unknown payload keys — we lost real server fields to this before makingpassthroughthe payload convention. New server fields now survive old clients. - Type the union once. One discriminated union on
typegives everyswitchfull narrowing. Noascasts 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.dataand 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
createProtocolis for. - Zod v3.25+ and v4 are both supported (peer dependency).
Contributing
npm test/npm run typecheck/npm run build— or./gates.shfor 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.