WebSocket reference
The backend pushes indexing progress over a WebSocket at /ws. The dashboard uses it to update progress bars without waiting for its next poll (every 3 seconds, or 15 seconds while the socket is connected). You can use it from any WebSocket client. For the REST side of the same data, see REST API.
The endpoint is ws://127.0.0.1:8001/ws for a local install. Behind the production nginx it is wss://<your-host>/ws. Source: websocket_service.py.
Connecting
Section titled “Connecting”Two checks run before your code talks to the endpoint: the request guard, then the key check.
Request guard
Section titled “Request guard”The same guard that protects HTTP also covers the handshake. It refuses the handshake when:
- the
Hostheader is not allowed (see Error shapes for the rules); - no key is configured and the TCP peer is not a loopback address;
- an
Originheader is present and is neither inCORS_ORIGINSnor the API’s own origin. A client that sends noOrigin, such as Node orwebsocat, passes.
The guard sends a close with code 1008 before the handshake completes. Because the close comes before accept, a browser reports it as an abnormal close (1006) rather than 1008. Check the backend log if a browser connection fails with 1006 and the key is correct.
Authentication
Section titled “Authentication”When CODESAGE_API_KEY is set, the endpoint needs the key. It reads it from either place:
| Where | Used by | Format |
|---|---|---|
| Subprotocol | Browsers, which cannot set headers on a WebSocket | codesage.key.<hex>, where <hex> is the UTF-8 bytes of the key written in hexadecimal |
X-API-Key header |
Servers, scripts and the production nginx | The key as plain text |
The subprotocol is checked first. A subprotocol with bad hexadecimal or invalid UTF-8 counts as no key. The key never appears in the URL, so access logs do not record it.
With no key configured, any connection passes this check.
Subprotocol echo
Section titled “Subprotocol echo”Always offer codesage as well as the key subprotocol. The server echoes codesage when you offered it, and echoes nothing otherwise. A browser closes a connection when it offered protocols and the server selected none, so a client that offers only codesage.key.<hex> fails.
["codesage", "codesage.key.6d792d6b6579"]Connection caps
Section titled “Connection caps”| Cap | Value |
|---|---|
| Open connections, all clients | 200 |
| Open connections per client address | 20 |
The client address is the TCP peer. Behind the production nginx, uvicorn takes it from X-Forwarded-For. A new connection past either cap is accepted and then closed, so the client sees the close code.
Close codes
Section titled “Close codes”| Code | Reason text | Cause | Should you retry? |
|---|---|---|---|
1008 |
Invalid or missing API key |
A key is configured and the connection has none or a wrong one | No. Fix the key |
1013 |
Too many connections; try again later |
A connection cap was reached | Yes, with backoff |
1011 |
Internal error |
An unexpected exception in the handler | Yes, with backoff |
1000 |
Normal close, sent by clients when they shut down |
The server accepts before it closes with 1008 or 1013, because a close before accept reaches browsers as 1006, which says nothing about why. The dashboard treats 1008 as an authentication failure and stops retrying.
Client frames
Section titled “Client frames”Send text frames containing one JSON object each. The type must be one of three values.
| Type | Fields | Reply |
|---|---|---|
subscribe |
repository_id: positive integer |
subscribed, or error |
unsubscribe |
repository_id: positive integer |
unsubscribed |
ping |
none | pong |
{"type": "subscribe", "repository_id": 7}{"type": "unsubscribe", "repository_id": 7}{"type": "ping"}repository_id must be an integer greater than zero. A boolean, a float, a string or 0 is refused. The server does not check that the repository exists, so you can subscribe before it is created. Subscribing twice to the same repository is harmless and counts once.
A connection can hold at most 100 subscriptions. A subscribe past the cap gets an error frame, and the connection stays open.
A bad frame never closes the connection. It gets an error frame and the loop goes on.
Server frames
Section titled “Server frames”Every frame is {"type": ..., "data": {...}}. pong has no data.
connected
Section titled “connected”Sent once, right after the connection is accepted. Treat the connection as ready only after you see it.
{ "type": "connected", "data": { "message": "Connected to CodeSage real-time updates", "timestamp": "2026-10-26T14:00:00.123456+00:00" }}subscribed and unsubscribed
Section titled “subscribed and unsubscribed”{"type": "subscribed", "data": {"repository_id": 7}}{"type": "unsubscribed", "data": {"repository_id": 7}}unsubscribed is sent even if you were not subscribed.
{"type": "pong"}data.message |
Cause |
|---|---|
Invalid JSON |
The frame is not valid JSON |
Message must be a JSON object |
The JSON is an array, string, number or null |
Unknown message type |
type is missing, not a string, or not one of the three client types |
repository_id must be a positive integer |
subscribe or unsubscribe without a valid repository_id |
At most 100 subscriptions per connection |
A subscribe would be the 101st |
{"type": "error", "data": {"message": "Unknown message type"}}repository_status
Section titled “repository_status”Sent to every connection subscribed to that repository.
| Field | Type | Always present | Meaning |
|---|---|---|---|
repository_id |
integer | Yes | The repository |
status |
string | Yes | processing, completed or failed |
name |
string or null | Yes | Repository name |
processed_files |
integer | Yes | Files stored so far |
total_files |
integer | Yes | Files selected for the run |
timestamp |
string | Yes | ISO 8601 UTC time the frame was built |
message |
string | No | Present on the final frame |
error |
string | No | Present only when the run failed. Up to 500 characters |
A pending status is never broadcast. The first frame of a run is processing.
{ "type": "repository_status", "data": { "repository_id": 7, "status": "processing", "name": "octo/demo", "processed_files": 0, "total_files": 0, "timestamp": "2026-10-26T14:00:03.114201+00:00" }}{ "type": "repository_status", "data": { "repository_id": 7, "status": "processing", "name": "octo/demo", "processed_files": 200, "total_files": 214, "timestamp": "2026-10-26T14:00:19.552870+00:00" }}{ "type": "repository_status", "data": { "repository_id": 7, "status": "completed", "name": "octo/demo", "processed_files": 214, "total_files": 214, "timestamp": "2026-10-26T14:00:31.907316+00:00", "message": "Processing completed successfully" }}When a run completes but left something out, message carries the Partially indexed: ... text instead.
{ "type": "repository_status", "data": { "repository_id": 7, "status": "failed", "name": "octo/demo", "processed_files": 0, "total_files": 0, "timestamp": "2026-10-26T14:00:08.230044+00:00", "message": "Processing failed", "error": "No supported code files found in repository" }}Broadcast timing
Section titled “Broadcast timing”A frame goes out:
- When a run starts. The status is already
processing, even if the run is still waiting for one of the two run slots. - After each batch of 200 files is stored, with the new
processed_files. - When the run ends, as
completedorfailed.
total_files is 0 until the first batch is stored, because the file count is only known after the repository is fetched. Deleting a repository sends no frame; it drops that repository’s subscribers.
A frame is only built if at least one connection is subscribed. Nothing replays missed frames, so after a reconnect, subscribe again and read /status once to catch up.
Send timeout and slow clients
Section titled “Send timeout and slow clients”Each send to a client has a 5-second timeout. A client that fails or stalls is removed and its socket is closed. Sends to all subscribers run at the same time, so several stalled clients cost one timeout, not one each. A processing run is never delayed by a client that stopped reading.
The server sends no pings of its own and has no application-level idle timeout. The production nginx closes idle /ws connections after an hour, so send a ping at least that often. The dashboard pings every 25 seconds.
JavaScript client
Section titled “JavaScript client”This client runs in Node 22 or later, which has a global WebSocket. In a browser, replace the two process.env lines with constants. It follows frontend/src/services/websocket.ts: it offers both subprotocols, waits for connected before subscribing, pings every 25 seconds, treats 1008 as fatal and reconnects with jittered exponential backoff otherwise.
const URL = process.env.CODESAGE_WS_URL ?? "ws://127.0.0.1:8001/ws";const KEY = process.env.CODESAGE_API_KEY ?? "";const REPOSITORY_IDS = [7];
const HEARTBEAT_MS = 25_000;const BASE_DELAY_MS = 1_000;const MAX_DELAY_MS = 30_000;
// The key is sent as hex in a subprotocol so it never appears in a URL or a log.function protocols() { if (!KEY) return ["codesage"]; const hex = Array.from(new TextEncoder().encode(KEY), (b) => b.toString(16).padStart(2, "0"), ).join(""); return ["codesage", `codesage.key.${hex}`];}
let attempts = 0;
function connect() { const socket = new WebSocket(URL, protocols()); let heartbeat = null; let awaitingPong = false;
socket.onmessage = (event) => { awaitingPong = false; // any frame proves the connection is alive const frame = JSON.parse(String(event.data));
switch (frame.type) { case "connected": attempts = 0; for (const id of REPOSITORY_IDS) { socket.send(JSON.stringify({ type: "subscribe", repository_id: id })); } heartbeat = setInterval(() => { if (awaitingPong) return socket.close(4000, "heartbeat timeout"); awaitingPong = true; socket.send(JSON.stringify({ type: "ping" })); }, HEARTBEAT_MS); break;
case "repository_status": { const u = frame.data; console.log(`${u.name} ${u.status} ${u.processed_files}/${u.total_files}`); if (u.error) console.error(` failed: ${u.error}`); else if (u.message) console.log(` ${u.message}`); break; }
case "error": console.warn("server error:", frame.data?.message); break;
default: // subscribed, unsubscribed, pong break; } };
socket.onclose = (event) => { clearInterval(heartbeat); if (event.code === 1008) { console.error("Key rejected (1008). Check CODESAGE_API_KEY."); return; // retrying cannot help } const delay = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** attempts++); setTimeout(connect, Math.round(delay * (0.5 + Math.random() * 0.5))); };}
connect();Run it with CODESAGE_API_KEY=... node codesage-watch.mjs. Add a repository in another terminal and you see the frames arrive.
Related pages
Section titled “Related pages”- Web dashboard shows where progress appears.
- Architecture shows how the run broadcasts status.
- Security explains the key, host and origin checks.