Frames are JSON objects sent as WebSocket text messages in both directions. The frame type is identified by which discriminator key is present (start_context, send_text, audio_chunk, and so on).
Client → Server frames
start_context
Opens a new context. The server replies with context_started.
Example:
Both voice_id and model_id are required. Omitting either returns an error frame containing the raw validation message:
model_id is required. Send "default" — it is echoed back on context_started.
An unknown voice_id returns a clean error frame rather than tearing down the connection:
send_text
Appends text to an open context. The server begins generating incrementally.
Sending text for a context that was never started returns {"error": "unknown context_id: ..."}.
Include trailing whitespace when you send consecutive fragments ("Welcome to Kova. ") so the model treats them as sequential prose rather than concatenated tokens.
flush
Marks the end of the current utterance. The server finishes in-progress generation and emits flush_completed.
flush_id is optional — if you omit it the server generates a UUID and returns that in flush_completed (e.g. "060c4b09-7462-4fb5-ae89-cfc85121ea58"). Always send your own so you can match the reply to the request.
close_context
Releases server resources for a context. The WebSocket itself stays open and remains usable for new contexts.
close_context emits two frames, not one. First a flush_completed whose flush_id is the synthetic string "<context_id>:close", then context_closed:Closing also flushes any text you sent but never flushed, so audio can continue arriving after you request the close. If your read loop breaks on any flush_completed, it will exit early on this synthetic one — match on your own flush_id values instead.
Closing a context that doesn’t exist returns {"error": "unknown context_id: ..."}.
Server → Client frames
context_started
Sent after a successful start_context.
response_format here is fully resolved, including defaults you didn’t send. Starting a context with {"encoding":"mp3"} echoes back {"encoding":"mp3","sample_rate":32000,"bitrate":"128k"}. Read the format from this frame rather than assuming — it’s the authoritative answer for how to interpret the bytes that follow.
audio_chunk
Audio bytes for a context.
Default when no response_format is set: base64-encoded headerless signed 16-bit little-endian PCM, 32000 Hz, mono.
As on the streaming HTTP endpoint, mp3 over WebSocket arrives as raw MPEG frames with no ID3 header.
timestamps
Word timing, sent only when the context was started with timestamps: true.
Timestamps arrive incrementally, one to three words per frame, exactly as on the streaming HTTP endpoint. Append them in arrival order; never overwrite. start_seconds are absolute offsets from the start of the utterance.
flush_completed
Emitted after every audio_chunk for the flushed utterance has been sent.
flush_id is your value, a server-generated UUID if you omitted one, or the synthetic "<context_id>:close" when triggered by close_context.
context_closed
Confirms a context has been released. Always preceded by a flush_completed.
error
A server-side error scoped to a context or flush.
error frames are not fatal. The socket stays open and other contexts keep working normally. The one exception is an authentication failure, which sends {"error": "INVALID_API_KEY"} and then closes the connection with code 1008 (policy violation).
Observed messages:
context_id is optional
Every frame’s context_id may be omitted. A session with no context_id anywhere works fine, and server frames come back with no context_id key at all.
Set it explicitly whenever you might run more than one context on a connection — it’s the only way to demultiplex.
Frame ordering
Within a single context_id:
context_started arrives before any audio_chunk or timestamps.
audio_chunk and timestamps interleave freely during generation, in no fixed ratio.
flush_completed arrives after the last audio_chunk for that utterance.
context_closed arrives last, always immediately after a flush_completed.
Frames from different contexts interleave freely and fairly. With two contexts generating at once, audio chunks alternate steadily between them — demultiplex on context_id.
Worked multi-context example
Two contexts, different voices and different formats, on one connection:
The server replies with two context_started frames carrying each context’s resolved format, then interleaved audio_chunk frames tagged A and B, then a flush_completed for each.
See also