STT for Agents
Real-time speech-to-text for voice AI agents with krisp-realtime-stt-agents-v1: fast partials, end-of-turn events and forced finalization.
Beta: Speech-to-Text is in beta. Models, messages and limits may change before general availability.
This page describes the WebSocket protocol for Krisp streaming speech-to-text (STT) with the krisp-realtime-stt-agents-v1 model, built for voice AI agents. It returns fast partial results and predicts the end of the user's turn.
Endpoint
- WebSocket URL:
wss://streaming.krisp.ai/stt - Auth: temporary session token. See Authentication.
Use the session token as the API key when opening the WebSocket. Do not send the master token on the WebSocket.
Authorization: api-key <session-token>If your client cannot set headers, pass the same value as the authorization query parameter. See the API overview.
Connection and handshake flow
1) Client initiates connection + sends handshake message
After opening the WebSocket connection with the session token, the client must send an initial JSON handshake that defines the session.
Handshake message (client → server, JSON):
{
"model": "krisp-realtime-stt-agents-v1",
"vocabulary": ["word1", "word2"],
"sample_rate": 16000,
"channels": 1,
"audio_format": "pcm_s16le",
"turn": {
"prediction": true,
"sensitivity": 0.9,
"max_silence": 2000,
"silence_validation": 100
},
"metadata": {}
}| Field | Type | Description |
|---|---|---|
model | string | krisp-realtime-stt-agents-v1. |
vocabulary | string[] | Optional list of custom vocabulary terms. |
sample_rate | number | Audio sample rate in Hz. Must be 16000. |
channels | number | Number of audio channels. Use 1. |
audio_format | string | pcm_s16le (PCM signed 16-bit little-endian, default) or opus. |
turn.prediction | boolean | true or false. Enables or disables turn (end-of-turn) prediction. Default: true. |
turn.sensitivity | number | End-of-turn sensitivity. Range: 0 – 1. Default: 0.9. 0 means more sensitive, 1 means less sensitive. |
turn.max_silence | number (ms) | Maximum silence in ms before the turn is ended. Range: 400 – 4000. Default: 2000. |
turn.silence_validation | number (ms) | Maximum silence in ms for validating the turn detection model's result. Range: 10 – 1000. Default: 100. |
metadata | object | Optional object echoed in the ready response. Maximum 4096 bytes when JSON-encoded. |
2) Server acknowledges readiness (or returns an error)
Once the server validates the handshake and is ready to receive audio, it responds with a ready message.
Ready response (server → client, JSON):
{
"type": "ready",
"request_id": "9f2c...",
"tracks_ms": [2400, 1200, 480],
"metadata": {}
}request_id: unique identifier for the streaming session. Include it if you contact Krisp support.tracks_ms: delays in ms of the decoders used for partial results. Each partial word'ssrcis one of these values.metadata: echo of themetadataobject from the handshake.
Do not send audio before this message. If no handshake is received within 15 seconds, the server closes the session.
Error response
If the server cannot accept the session, it responds with an error message and closes the connection. Besides the errors below, the common errors apply.
Error response (server → client, JSON):
{
"type": "error",
"request_id": "9f2c...",
"error": {
"code": "invalid_api_key",
"status_code": 401,
"message": "Invalid Authorization header.",
"details": {
"hint": "Check authorization header."
}
}
}error.code | status_code | Description |
|---|---|---|
missing_authorization_header | 401 | Authorization header is missing |
invalid_api_key | 401 | Authorization header is malformed |
unauthorized | 403 | Session token is not valid or has expired |
invalid_client_hello | 400 | Handshake is missing or invalid |
client_hello_timeout | 408 | Handshake was not received in time |
chunk_too_large | 413 | A message exceeded 65,536 bytes |
internal_server_error | 500 | Unexpected server error |
Audio streaming
3) Client streams audio frames (raw bytes)
After receiving type: "ready", the client sends raw audio bytes as binary WebSocket messages.
- Message type: binary (raw bytes)
- Encoding: according to
audio_formatin the handshake:pcm_s16le(PCM signed 16-bit little-endian) oropus - Sample rate: 16,000 Hz
- Channels: 1 (mono)
- Recommended chunk duration: 80 ms, or multiples of 80 ms
- Maximum message size: 65,536 bytes
Stream audio in realtime. Do not wrap audio in JSON.
Server frames
Every server message is a JSON text frame, and all frames carry a type. Finals and partials are both transcript frames and differ in transcript.isFinal.
Times are milliseconds of audio from the session's first sample, except in turn frames, where they are seconds.
4) Final transcript: isFinal: true
isFinal: true{
"type": "transcript",
"metadata": {},
"transcript": {
"words": [
{
"text": " weather",
"start": 12480,
"end": 12880,
"language": "en",
"language_confidence": 0.9921
}
],
"isFinal": true
}
}wordsholds one entry: a whole word with turn prediction, a word piece without it.textkeeps its leading space, so joiningtextin arrival order gives the transcript.startandendbound the word.languageandlanguage_confidenceare present when the server runs language ID.metadatais always empty.- A final never changes and is never repeated.
- Finals trail speech by about 2.4 s, except at a turn end, when the turn's pending words arrive at once.
5) Partial transcript: isFinal: false
isFinal: false{
"type": "transcript",
"metadata": {},
"transcript": {
"words": [
{ "text": " is", "start": 13040, "end": 13200, "src": 1200 },
{ "text": " nice", "start": 13200, "end": 13600, "src": 480 },
{ "text": " today", "start": 13600, "end": 14000, "src": 480 }
],
"isFinal": false
}
}wordsis the whole partial region: everything after the last final, one entry per word.srcis the delay in ms of the decoder that produced the word, one ofready.tracks_ms:480for the newest words,1200for words revised once.- Each partial frame replaces all earlier partial content, and
words: []clears it. - Show the finals followed by the latest partial frame.
- Partial words may change or disappear in the next frame.
- A partial frame is sent only when the words change.
| Field | Description |
|---|---|
transcript.isFinal | true when this result is committed; false for a live hypothesis |
words[].text | Word text, with leading space |
words[].start | Start time in milliseconds from the start of the stream |
words[].end | End time in milliseconds from the start of the stream |
words[].language | Detected language (finals only, when language ID runs) |
words[].language_confidence | Language confidence (finals only, when language ID runs) |
words[].src | Decoder delay in ms, one of ready.tracks_ms (partials only) |
6) Turn: type: "turn"
type: "turn"{
"type": "turn",
"confidence": 0.934,
"reason": "model",
"detected_at": 14560,
"finalized_until": 14640,
"processing_duration": 95
}| Field | Meaning |
|---|---|
confidence | The end-of-turn model's probability at the decision; for a client finalize, its latest value. |
reason | model, timeout or client: whether the turn was ended by the model, by turn.max_silence, or by the client's force finalization. |
detected_at | Audio time of the decision. |
finalized_until | Audio time up to which the turn was finalized. |
processing_duration | Server time spent publishing the turn's pending words, in seconds. |
7) Force finalization
Force the end of a turn at any point during the stream:
{"type": "finalize"}8) Stream end
To end the stream, the client sends one text frame after its last audio message:
{"type": "end_of_audio"}The server then finishes the session and closes the socket itself. The client shouldn't close it; it should read frames until end arrives.
After end_of_audio, the last frame the server sends is end, and it closes the socket right after:
{
"type": "end",
"language": {
"overall_language": "en",
"overall_confidence": 0.982,
"current_language": "en",
"current_confidence": 0.991,
"per_language": {
"de": 0.003,
"en": 0.982,
"es": 0.004,
"fr": 0.005,
"ru": 0.003,
"hy-AM": 0.003
}
}
}The last frame of a session. language is present when the server runs language ID:
overall_*averages the whole session.current_*covers the last 0.5 s.per_languageholds each language's mean probability.
Example (Python)
Streams a 16 kHz, mono, 16-bit PCM WAV file at realtime pace and prints finals, partials and turn events. Requires pip install "websockets>=14".
import asyncio
import json
import sys
import wave
import websockets
SESSION_TOKEN = "YOUR_SESSION_TOKEN" # see the Authentication page
URL = "wss://streaming.krisp.ai/stt"
CHUNK_MS = 80
async def main(path: str) -> None:
headers = {"Authorization": f"api-key {SESSION_TOKEN}"}
async with websockets.connect(URL, additional_headers=headers) as ws:
await ws.send(json.dumps({
"model": "krisp-realtime-stt-agents-v1",
"sample_rate": 16000,
"channels": 1,
"audio_format": "pcm_s16le",
"turn": {"prediction": True, "sensitivity": 0.9, "max_silence": 2000},
}))
hello = json.loads(await ws.recv())
if hello["type"] != "ready":
raise RuntimeError(hello)
async def send_audio() -> None:
with wave.open(path, "rb") as wav:
frames = 16000 * CHUNK_MS // 1000
while chunk := wav.readframes(frames):
await ws.send(chunk)
await asyncio.sleep(CHUNK_MS / 1000)
await ws.send(json.dumps({"type": "end_of_audio"}))
sender = asyncio.create_task(send_audio())
async for message in ws:
msg = json.loads(message)
if msg["type"] == "transcript":
kind = "final" if msg["transcript"]["isFinal"] else "partial"
text = "".join(w["text"] for w in msg["transcript"]["words"])
print(kind, text)
elif msg["type"] == "turn":
print("end of turn", msg["reason"], msg["confidence"])
elif msg["type"] == "end":
break
await sender
asyncio.run(main(sys.argv[1]))Changelog
- 2026-10-01: Page created.
Updated 3 days ago

