Skip to content

Live dictation stream

A dictation client opens a session with an API key, receives a signed ticket, and opens one WebSocket. Speech to text and corrections run over the socket without a job per request. Citadel relays every frame to the speech runtime unchanged and records counts and timings per request. Citadel stores no audio, no transcript and no chat content. Base URL: https://citadel.albastov.com/v1/dictation.

Note. The stream is experimental and off unless the owner switches it on. Scope dictation is owner-only: a member's key cannot carry it. The examples need a key with scope dictation; the example checker skips them.

Flow

  1. POST /v1/dictation/sessions with an API key. The response contains session_id, ticket and endpoints.
  2. Open the relay endpoint URL. Send {"op":"auth","ticket":"<ticket>"} as the first frame within 5 seconds.
  3. Send requests and audio. Read replies in any order; each reply carries the request id.
  4. After a disconnect, call POST /v1/dictation/sessions/{id}/ticket and open the socket again with the new ticket. Resend unanswered requests.

POST /v1/dictation/sessions

Requires an API key with scope dictation. The body is optional.

FieldTypeDefaultMeaning
clientstring, at most 64 printable characters""Client name and version, shown in the session list
devicestringhl1Inference device; hl1 is the only accepted value
Open a session
curl https://citadel.albastov.com/v1/dictation/sessions \
  -H "Authorization: Bearer $CITADEL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"client": "iridium-live 2.4"}'

Returns 201:

JSON
{
  "session_id": "ds_k3m2c7x4q5n6p2r7s3t4u5v6",
  "ticket": "eyJhbGciOiJFZERTQSIsImtpZCI6Ii4uLiIsInR5cCI6IkpXVCJ9.eyJzZXNzaW9uX2lkIjoiLi4uIn0.c2lnbmF0dXJl",
  "expires_at": 1793567400000,
  "endpoints": [
    {"kind": "relay", "url": "wss://citadel.albastov.com/v1/dictation/stream?session=ds_k3m2c7x4q5n6p2r7s3t4u5v6"}
  ]
}

expires_at is the ticket expiry in Unix milliseconds. Try endpoints in order; a later version adds endpoints of other kinds to the same list.

POST /v1/dictation/sessions/{id}/ticket

Requires an API key with scope dictation of the account that opened the session. Returns 200 with the same body as session creation and a new ticket for the same session. A session of another account fails with 404 session_not_found.

Session ticket

The ticket is a compact JWS signed with Ed25519: base64url(header).base64url(claims).base64url(signature).

PartContent
Header{"alg":"EdDSA","kid":"<key id>","typ":"JWT"}
session_idSession the ticket opens
key_idId of the API key that opened the session
expExpiry, Unix seconds; 5 minutes after issue
iat, account, device, jtiIssue time, account id, device, unique ticket id

A ticket opens one socket at a time and only before exp. An open socket outlives the ticket. A second socket with a ticket that holds an open socket fails with ticket_in_use. A socket opened with a new ticket of the same session closes the older socket of the session with 4409 replaced. A ticket issued before the ticket of the open socket fails with ticket_stale.

Citadel checks the API key that opened the session when a socket opens and every 30 seconds while the socket is open. A revoked key, or a key without scope dictation, closes the socket with 4401 invalid_key or 4403 scope_missing.

GET /v1/dictation/keys

Requires no key. Returns the ticket signing keys:

JSON
{
  "alg": "EdDSA",
  "kid": "Xq2mVb7NcL0pRt4s",
  "ticket_key": "2l5yWl0B6bO0cG1o3d7m0Ff3eU6k8n1qYw2s4v9x0zA=",
  "keys": [
    {"kid": "Xq2mVb7NcL0pRt4s", "alg": "EdDSA", "public_key": "2l5yWl0B6bO0cG1o3d7m0Ff3eU6k8n1qYw2s4v9x0zA=",
     "active": true, "created_at": 1793567100000, "retired_at": null}
  ]
}

ticket_key is the active key: the raw 32-byte Ed25519 public key, base64. After a key rotation, keys also lists the previous key for 5 minutes, the lifetime of the tickets the previous key signed. Responses include Cache-Control: public, max-age=60. One client address may call the route 60 times a minute.

GET /v1/dictation/stream

WebSocket upgrade. Query parameter: session=<session_id>. Never put the ticket in the URL.

Authenticate in one of two ways:

MethodRefusal
First frame {"op":"auth","ticket":"..."} within 5 secondsClose frame with code 4000 + status and the error code as reason
Header Authorization: Bearer <ticket> on the upgradeHTTP status and JSON error body; no WebSocket

After authentication, every frame passes unchanged in both directions, in order per direction. Citadel sends no frame of its own. A refusal by the speech runtime arrives as a close frame (4000 + status) or, with the header method, as the runtime's HTTP status and body.

Transcribe one phrase
import json, os, requests
from websockets.sync.client import connect

base = "https://citadel.albastov.com/v1"
session = requests.post(f"{base}/dictation/sessions",
                        headers={"Authorization": f"Bearer {os.environ['CITADEL_API_KEY']}"},
                        json={"client": "example"}).json()
with connect(session["endpoints"][0]["url"]) as ws:
    ws.send(json.dumps({"op": "auth", "ticket": session["ticket"]}))
    ws.send(json.dumps({"op": "transcribe", "id": 1, "final": True}))
    ws.send(open("phrase.pcm", "rb").read())  # 16 kHz mono PCM16 little-endian
    print(json.loads(ws.recv()))

Limits

LimitValue
Frame size1 MiB (32 seconds of audio); a larger frame closes the socket with 1009
Frames waiting for the device1,024 frames or 4 MiB per socket, 8 MiB across the device's sockets; past a limit the socket closes with 1008 flood
Frames waiting for the client1,024 frames or 4 MiB, and 10 seconds per write; past either the socket closes with 1008 slow_reader
Open sockets4 per account; a fifth closes with 4429 too_many_streams. A new socket of a session the account holds replaces the older one
Socket lifetime12 hours; then the socket closes with 1001 max_lifetime. Reconnect with a new ticket
Upgrades60 a minute per client address; past the limit the upgrade fails with 429 rate_limited
Sockets waiting for the auth frame64 on the server; past the limit the upgrade fails with 503 auth_backlog
KeepaliveCitadel sends a WebSocket ping every 20 seconds; a ping without a pong within 20 seconds closes the socket with 1001 keepalive_timeout

Send a frame at least every 90 seconds, or answer pings: the network edge closes a WebSocket after 100 seconds without traffic. {"op":"ping"} is an end-to-end check and receives {"op":"pong"} from the runtime. The runtime accepts audio up to 120 seconds, but a frame over 1 MiB never reaches the runtime: send a final at each pause.

Protocol

Client text frames are one JSON object each; audio is a binary frame. id is a whole number or a string of 1 to 64 characters, unique among the requests in flight. Every request receives exactly one final reply: text, done or error.

Client sendsRuntime answers
{"op":"transcribe","id":n,"final":bool}, then one binary frame of 16 kHz mono PCM16 little-endian, at most 1 MiB through Citadel{"op":"text","id":n,"final":bool,"text":"...","model":"parakeet","model_state":"loaded","ms":{...}}
{"op":"chat","id":n,"body":{...}} with an OpenAI chat body including model and messages{"op":"delta","id":n,"content":"..."} per token chunk, then {"op":"done","id":n,"text","finish_reason","model","usage","ms":{...}}
{"op":"cancel","id":n}{"op":"error","id":n,"code":"cancelled"} for the cancelled request
{"op":"ping","ts"?:n}{"op":"pong","ts"?:n}

ms contains received (Unix milliseconds when the runtime read the request) and total (milliseconds from received to the reply), plus queued and inference for transcribe, and queued and ttft for chat.

Final phrases run before captions. A new caption request ("final": false) replaces the session's pending caption; the replaced request receives error with code replaced.

Request errors

{"op":"error","id":n,"code":"...","message":"...","ms":{...}}. The socket stays open.

codeMeaning
bad_requestFrame is not JSON, or id, final or body is missing or invalid
unknown_opop is not transcribe, chat, cancel or ping
duplicate_idid is in flight
unexpected_audioBinary frame without a preceding transcribe
bad_audioEmpty or odd-length audio
too_largeAudio longer than 120 seconds
replacedA newer caption took the caption's place
cancelledThe client cancelled the request
asr_unavailable, asr_failedSpeech to text is down or failed
model_not_loadedThe chat model is not loaded
chat_failedThe LLM router refused the request or the stream broke

Errors

HTTP errors use the Citadel error body: {"ok": false, "error": "dictation-off", "code": "dictation_off", "message": "..."}.

StatuscodeRouteCause
400bad_requestsessionsInvalid body or client
400unknown_devicesessionsdevice is not hl1
400session_requiredstreamsession missing or malformed
400auth_requiredstreamFirst frame is not {"op":"auth"}
401ticket_required, ticket_invalid, ticket_expiredstreamTicket missing, malformed, wrongly signed or expired
401invalid_keysessions, ticket, streamAPI key unknown or revoked
403scope_missingsessions, ticket, streamKey without scope dictation
403account_cannotsessions, ticket, streamKey of an account other than the owner's
403ticket_wrong_sessionstreamTicket of another session
404session_not_foundticketUnknown session, or a session of another account
409ticket_in_usestreamTicket holds an open socket
409ticket_stalestreamA newer ticket of the session holds the open socket
426upgrade_requiredstreamPlain GET
429rate_limitedstream, keysClient address over 60 calls a minute
429too_many_streamsstreamAccount holds 4 open sockets
503dictation_offsessions, ticket, streamDictation is switched off
503device_offlinesessions, ticket, streamInference device not connected
503device_unsupportedsessions, ticket, streamInference device cannot relay WebSockets
503device_busystreamInference device holds its maximum of relayed sockets
503auth_backlogstream64 sockets wait for their auth frame
503ticket_key_unavailablesessions, ticket, keysTicket signing is not configured, or the seal key cannot open the stored ticket key
504device_timeoutstreamRuntime did not answer the upgrade within 10 seconds

Close codes

CodeReasonCause
1000Normal close by the client or the runtime
1001dictation turned offDictation switched off; open sockets close
1001keepalive_timeoutClient missed a ping
1001max_lifetimeSocket open for 12 hours; reconnect with a new ticket
1008flood, slow_readerFrame limits above
1009Frame over 1 MiB
1012device disconnectedInference device disconnected; reconnect with a new ticket
1013device_busyInference device connection did not take a frame within 5 seconds; reconnect
4000 + statuserror codeRefusal, as in the table above: 4401 ticket_expired, 4401 invalid_key, 4403 scope_missing, 4403 ticket_wrong_session, 4404 not_found (runtime switched off), 4408 auth_timeout, 4409 ticket_in_use, 4409 ticket_stale, 4429 too_many_streams, 4504 device_timeout
4409replacedA newer socket of the session opened

Other codes from the runtime pass to the client unchanged.

Records

Citadel records sessions, sockets (frame and byte counts) and one row per request from the runtime's summary events: operation, outcome, model, audio length, character and token counts, and the ms timings. Records are kept 90 days. Citadel writes records in batches after the reply; while the database is unreachable, the records of that period are lost and the stream continues.