Socket Protocol
The realtime channel between the studio and the agents. It is a Socket.IO server mounted on the studio's own HTTP server, so the browser and the agents share one origin and one port.
Connecting
| Property | Value |
|---|---|
| Path | /api/socket.io |
| Transports | websocket, with polling as a fallback |
| Reconnection | 10 attempts, 5 seconds apart |
| Direction | The agent always dials out. The studio never connects to an agent. |
import { io } from "socket.io-client";
const socket = io("http://localhost:3000", {
path: "/api/socket.io",
transports: ["websocket", "polling"],
reconnection: true,
reconnectionAttempts: 10,
reconnectionDelay: 5000,
});Socket.IO's default path is /socket.io. DK Studio mounts it under /api/socket.io, because the
studio's own API lives under /api. A client that leaves path unset will not connect.
The agent reconnects on its own: after a dropped connection it retries ten times, five seconds
apart, and re-runs the handshake on each attempt. Presence follows the socket, so an agent that
reconnects is online again as soon as its auth request is accepted.
The message envelope
Every message on the channel has the same shape:
{
"v": 4,
"type": "command",
"kind": "request",
"id": "6f1cβ¦",
"timestamp": "2026-10-21T08:14:22.101Z",
"payload": { }
}| Field | Meaning |
|---|---|
v | The protocol version. 4 today. |
type | The message type. A closed set of thirteen. |
kind | The shape of the interaction: request, response or event. |
id | A UUID identifying the interaction. |
timestamp | ISO 8601, when the message was created. |
payload | The type-specific body. |
type says what the message is about; kind says what the interaction is. The two are
independent, which is why a command and its answer share the type command and differ only in
kind.
The three kinds
| Kind | Meaning | Constraint |
|---|---|---|
request | Starts an interaction. | Expects exactly one response, with the same id. |
response | Answers a request. | Carries the same id as the request it answers. |
event | A one-way notification. | No answer expected; the id is for deduplication and tracing. |
The response convention
Every response carries the same body:
{ "ok": true, "data": { } }{ "ok": false, "error": { "code": "AUTH_FAILED", "message": "Invalid agent credentials" } }ok says whether the interaction succeeded. On success data holds the result; on failure
error holds a code from the error vocabulary and a human-readable
message.
The id of a command
For the type command, the id is also the command's own identifier β the same value the studio
stores in agent_commands.id. A command delivered over the socket and the same command delivered
by the polling fallback therefore share one id, and a result always points back at the command it
belongs to.
Versioning
The auth request carries protocolVersion, and the studio's answer carries the version it
speaks. If they do not match, the studio answers ok: false with PROTOCOL_VERSION_UNSUPPORTED
and closes the socket.
The type catalogue
Fifteen types, in nineteen type-and-kind combinations.
| Type | Kind | Direction | Purpose |
|---|---|---|---|
auth | request | agent β studio | The authentication handshake. |
auth | response | studio β agent | The outcome of the handshake. |
command | request | studio β agent | A command to execute. |
command | response | agent β studio | The outcome of a command. |
agent_status | event | agent β studio | The current task and the running total. |
ping | request | studio β agent | The keepalive check. |
ping | response | agent β studio | The keepalive answer. |
device_event | event | agent β studio | A device was attached or removed. |
automation_event | event | agent β studio | A device's automation endpoint came up or went away. |
device_list | event | agent β studio | Every device the bench currently holds. |
device_setup_request | request | agent β studio | The bench asks the studio to put a driver on a device. |
device_setup_request | response | studio β agent | Whether the setup chain started, and why not. |
dashboard_auth | request | dashboard β studio | The dashboard's handshake. |
dashboard_auth | response | studio β dashboard | The outcome of the dashboard's handshake. |
agent_list | event | studio β dashboard | A full snapshot of the agents. |
agent_update | event | studio β dashboard | One agent changed. |
device_update | event | studio β dashboard | The bench's device list changed. |
dependency_manifest | event | studio β agent | The dependency list the bench is checked against. |
health_report | event | agent β studio | The bench's health check result. |
screen_frame | event | agent β studio | One frame of a device's screen. |
The Socket.IO event name is the type. The direction is what separates a request from its
response.
A receiver that meets a type outside this list ignores it silently: no error, no disconnect.
The authentication handshake
The agent opens the socket and authenticates as its first act:
// agent β studio
{
"v": 4,
"type": "auth",
"kind": "request",
"id": "6f1cβ¦",
"timestamp": "2026-10-21T08:14:20.004Z",
"payload": {
"agentId": "0f2cβ¦",
"agentSecret": "β¦64 hexβ¦",
"protocolVersion": 4,
"machine": {
"id": "0b0497452920da9a2a8135245914cf47",
"hostname": "bench-01",
"os": "darwin",
"arch": "arm64"
}
}
}The machine block is the bench's own identity, collected by the agent's server process. The
browser can only report the browser's user agent, which says nothing about the machine. The
id is a stable fingerprint: it survives a restart and changes with the machine.
The studio looks the agent up by id, compares the secret, and answers on the same auth type:
// studio β agent
{
"v": 4,
"type": "auth",
"kind": "response",
"id": "6f1cβ¦",
"timestamp": "2026-10-21T08:14:20.031Z",
"payload": {
"ok": true,
"data": {
"agentId": "0f2cβ¦",
"name": "Bench 01",
"capabilities": [],
"protocolVersion": 4
}
}
}or
{
"v": 4,
"type": "auth",
"kind": "response",
"id": "6f1cβ¦",
"timestamp": "β¦",
"payload": {
"ok": false,
"error": { "code": "AUTH_FAILED", "message": "Invalid credentials" }
}
}A failed handshake is followed by a disconnect. The agent stays disconnected until it is reconnected with valid credentials.
The secret compared here is agents.agent_secret, which the studio issues when registration
succeeds. The agent's other credential, the API key, belongs to POST /api/agent/auth and is never
sent over the socket.
From the moment the handshake succeeds the studio counts the agent as online. That status is not stored β it is derived from the live socket on every read, which is why it is accurate.
The handshake also opens a presence record. agent_sessions holds one row per stretch of time
the agent was connected: when it came up, when it went away, and why it ended. A dropped
connection is not the end of the record β the socket may come back, and a reconnection
continues the same stretch rather than starting a new one.
The agent secret travels in the socket's payload. The channel is only as private as the transport carrying it β use TLS (or a trusted private network) for a studio that is not on localhost.
Sending a command
The studio emits a command request to the agent's socket:
{
"v": 4,
"type": "command",
"kind": "request",
"id": "0a3bβ¦",
"timestamp": "β¦",
"payload": {
"command": "shell",
"args": { "script": "uname -a" },
"timeout": 300000
}
}The id here is the command's identifier.
The agent acknowledges it with an agent_status event, then runs it, then answers on the same
command type:
// agent β studio, the acknowledgement
{
"v": 4,
"type": "agent_status",
"kind": "event",
"id": "β¦",
"timestamp": "β¦",
"payload": { "taskId": "0a3bβ¦", "elapsedMs": 0, "completedCount": 7 }
}// agent β studio, the outcome
{
"v": 4,
"type": "command",
"kind": "response",
"id": "0a3bβ¦",
"timestamp": "β¦",
"payload": {
"ok": true,
"data": {
"status": "success",
"output": { "stdout": "Linux β¦", "stderr": "", "exitCode": 0, "durationMs": 128 }
}
}
}The acknowledgement matters. When the studio sees a taskId, it moves the command row to
in_progress, so the polling fallback stops offering the same command. If the acknowledgement
never arrives β the push was lost β the row stays pending and the fallback picks it up.
Work state
The agent reports its own state, and the state is derived from one field: taskId is the command
being executed, or null when the agent is idle.
taskId | Work state |
|---|---|
| A command id | busy |
null | idle |
elapsedMs is how long the current task has been running, and completedCount is how many
commands this connection has finished. Work state is live data from the agent: the studio holds it
in memory and never writes it to the database.
Device events
The agent watches the machine for serial devices and reports changes:
{
"v": 4,
"type": "device_event",
"kind": "event",
"id": "β¦",
"timestamp": "β¦",
"payload": {
"type": "serial",
"event": "connected",
"device": {
"path": "/dev/ttyUSB0",
"manufacturer": "FTDI",
"serialNumber": "β¦",
"vendorId": "0403",
"productId": "6001"
},
"timestamp": "β¦"
}
}event is connected or disconnected. type is always serial β serial devices are the ones
the agent watches.
The device payload is metadata only. The bytes on the port are not forwarded to the studio.
Automation events
A device's automation endpoint lives on a host port of the bench. The studio cannot see whether it is still there, so the bench says so:
// agent β studio
{
"v": 4,
"type": "automation_event",
"kind": "event",
"id": "β¦",
"timestamp": "β¦",
"payload": {
"deviceId": "β¦",
"platform": "ios",
"status": "down",
"url": "http://127.0.0.1:8100",
"reason": "the endpoint stopped answering",
"timestamp": "β¦"
}
}status is up or down. On down the studio records that the device is no longer ready, and
the next task that reaches it runs the setup chain again.
Asking for a driver
The bench never improvises a driver. When it finds that the automation package is not on a device, it asks the studio to put one there β the signed driver lives on the studio, and the studio knows which build belongs to which platform:
// agent β studio
{
"v": 4,
"type": "device_setup_request",
"kind": "request",
"id": "β¦",
"timestamp": "β¦",
"payload": { "deviceId": "β¦", "platform": "ios" }
}The studio answers with a device_setup_request response: started says whether the chain is
running, and code and message say why not when it is not.
Screen frames
The studio can watch what a device under test is showing. The bench takes the picture and the studio relays it to the dashboards.
The studio starts and stops the watch with the screen_watch command:
// studio β agent
{
"v": 4,
"type": "command",
"kind": "request",
"id": "β¦",
"timestamp": "β¦",
"payload": {
"command": "screen_watch",
"args": { "platform": "ios", "deviceId": "β¦", "enabled": "true", "intervalMs": 1000 }
}
}enabled is "true" or "false". While the watch is on, the bench sends a screen_frame event
for every frame it takes:
// agent β studio
{
"v": 4,
"type": "screen_frame",
"kind": "event",
"id": "β¦",
"timestamp": "β¦",
"payload": {
"agentId": "β¦",
"platform": "ios",
"deviceId": "β¦",
"seq": 12,
"capturedAt": "2026-10-30T10:11:59.249Z",
"width": 480,
"height": 1039,
"mimeType": "image/jpeg",
"data": "β¦base64β¦"
}
}The frame is a JPEG, scaled down on the bench so that one frame costs about thirty kilobytes
rather than the six megabytes an iPhone screenshot weighs. seq counts the frames of that one
device, so a receiver can tell a new frame from a repeated one.
The bench stops the watch when the studio says so, and when the socket to the studio closes β there is no point photographing a screen nobody is looking at.
Keepalive
The studio sends a ping request to check that the link is alive; the agent answers with a
ping response. The last_seen_at column is refreshed by the REST heartbeat
POST /api/agent/heartbeat and by the agent's own presence on the socket.
ping is symmetric: an agent may send a ping request too, and the studio answers it the same
way. The agent's own console uses this to check that the studio is listening.
Dashboard events
The browser dashboard can open its own socket and receive presence updates.
| Event | Direction | Payload |
|---|---|---|
dashboard_auth | dashboard β studio | { token } |
dashboard_auth | studio β dashboard | { ok, data } or { ok, error } |
agent_list | studio β dashboard | { agents: [{ agentId, name, status, workState, β¦ }] } |
agent_update | studio β dashboard | { agent: { agentId, name, status, workState, β¦ } } |
The dashboard authenticates with the token it presents in dashboard_auth. The studio compares it
with DASHBOARD_WS_TOKEN and answers on the same event, or disconnects when it does not match.
agent_list is the full snapshot; agent_update is the same shape for a single agent, and
carries both the connection status and the work state.
When an agent's socket closes, the studio broadcasts an agent_update for it with
status: "offline" and no work state. The dashboard learns about a disconnect from the same
event it learns about a connect from β it never has to poll for presence.
The polling fallback
When the socket is unavailable, the agent falls back to HTTP:
- Every five seconds,
GET /api/agent/commands?agentId=β¦returns the agent's pending commands. The response carries the same envelopes the socket carries, so the agent has a single execution path. - The agent executes them.
- The agent posts each outcome to
POST /api/agent/commands/result, as acommandresponse envelope.
Both paths write to the same agent_commands rows, so the command history looks identical either
way. Only the latency differs.
The command's lifecycle is:
pending ββ(agent_status acknowledgement)βββΆ in_progress ββ(result arrives)βββΆ completed
An agent ignores a command it is already running, and keeps the outcome of the commands it has just finished, so a duplicate delivery never runs the work twice.
Compatibility
- The type list is closed, but adding a type is compatible: a receiver ignores unknown types silently.
- A payload may only gain optional fields. Required fields are never removed or changed.
- A breaking change takes the version up by one, negotiated during the handshake.
See also
- Command Reference β the payload each command takes.
- Architecture β how a command travels.
- Configuration β
DASHBOARD_WS_TOKENand the working directory. - Error Codes β the socket and HTTP error vocabularies.