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
POST /v1/dictation/sessionswith an API key. The response containssession_id,ticketandendpoints.- Open the
relayendpoint URL. Send{"op":"auth","ticket":"<ticket>"}as the first frame within 5 seconds. - Send requests and audio. Read replies in any order; each reply carries the request
id. - After a disconnect, call
POST /v1/dictation/sessions/{id}/ticketand 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.
| Field | Type | Default | Meaning |
|---|---|---|---|
client | string, at most 64 printable characters | "" | Client name and version, shown in the session list |
device | string | hl1 | Inference device; hl1 is the only accepted value |
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:
{
"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).
| Part | Content |
|---|---|
| Header | {"alg":"EdDSA","kid":"<key id>","typ":"JWT"} |
session_id | Session the ticket opens |
key_id | Id of the API key that opened the session |
exp | Expiry, Unix seconds; 5 minutes after issue |
iat, account, device, jti | Issue 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:
{
"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:
| Method | Refusal |
|---|---|
First frame {"op":"auth","ticket":"..."} within 5 seconds | Close frame with code 4000 + status and the error code as reason |
Header Authorization: Bearer <ticket> on the upgrade | HTTP 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.
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
| Limit | Value |
|---|---|
| Frame size | 1 MiB (32 seconds of audio); a larger frame closes the socket with 1009 |
| Frames waiting for the device | 1,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 client | 1,024 frames or 4 MiB, and 10 seconds per write; past either the socket closes with 1008 slow_reader |
| Open sockets | 4 per account; a fifth closes with 4429 too_many_streams. A new socket of a session the account holds replaces the older one |
| Socket lifetime | 12 hours; then the socket closes with 1001 max_lifetime. Reconnect with a new ticket |
| Upgrades | 60 a minute per client address; past the limit the upgrade fails with 429 rate_limited |
| Sockets waiting for the auth frame | 64 on the server; past the limit the upgrade fails with 503 auth_backlog |
| Keepalive | Citadel 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 sends | Runtime 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.
code | Meaning |
|---|---|
bad_request | Frame is not JSON, or id, final or body is missing or invalid |
unknown_op | op is not transcribe, chat, cancel or ping |
duplicate_id | id is in flight |
unexpected_audio | Binary frame without a preceding transcribe |
bad_audio | Empty or odd-length audio |
too_large | Audio longer than 120 seconds |
replaced | A newer caption took the caption's place |
cancelled | The client cancelled the request |
asr_unavailable, asr_failed | Speech to text is down or failed |
model_not_loaded | The chat model is not loaded |
chat_failed | The 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": "..."}.
| Status | code | Route | Cause |
|---|---|---|---|
400 | bad_request | sessions | Invalid body or client |
400 | unknown_device | sessions | device is not hl1 |
400 | session_required | stream | session missing or malformed |
400 | auth_required | stream | First frame is not {"op":"auth"} |
401 | ticket_required, ticket_invalid, ticket_expired | stream | Ticket missing, malformed, wrongly signed or expired |
401 | invalid_key | sessions, ticket, stream | API key unknown or revoked |
403 | scope_missing | sessions, ticket, stream | Key without scope dictation |
403 | account_cannot | sessions, ticket, stream | Key of an account other than the owner's |
403 | ticket_wrong_session | stream | Ticket of another session |
404 | session_not_found | ticket | Unknown session, or a session of another account |
409 | ticket_in_use | stream | Ticket holds an open socket |
409 | ticket_stale | stream | A newer ticket of the session holds the open socket |
426 | upgrade_required | stream | Plain GET |
429 | rate_limited | stream, keys | Client address over 60 calls a minute |
429 | too_many_streams | stream | Account holds 4 open sockets |
503 | dictation_off | sessions, ticket, stream | Dictation is switched off |
503 | device_offline | sessions, ticket, stream | Inference device not connected |
503 | device_unsupported | sessions, ticket, stream | Inference device cannot relay WebSockets |
503 | device_busy | stream | Inference device holds its maximum of relayed sockets |
503 | auth_backlog | stream | 64 sockets wait for their auth frame |
503 | ticket_key_unavailable | sessions, ticket, keys | Ticket signing is not configured, or the seal key cannot open the stored ticket key |
504 | device_timeout | stream | Runtime did not answer the upgrade within 10 seconds |
Close codes
| Code | Reason | Cause |
|---|---|---|
1000 | Normal close by the client or the runtime | |
1001 | dictation turned off | Dictation switched off; open sockets close |
1001 | keepalive_timeout | Client missed a ping |
1001 | max_lifetime | Socket open for 12 hours; reconnect with a new ticket |
1008 | flood, slow_reader | Frame limits above |
1009 | Frame over 1 MiB | |
1012 | device disconnected | Inference device disconnected; reconnect with a new ticket |
1013 | device_busy | Inference device connection did not take a frame within 5 seconds; reconnect |
4000 + status | error code | Refusal, 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 |
4409 | replaced | A 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.