DK Studio/Documentation
ProductChangelog

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

PropertyValue
Path/api/socket.io
Transportswebsocket, with polling as a fallback
Reconnection10 attempts, 5 seconds apart
DirectionThe 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,
});
The path is not the default

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": { }
}
FieldMeaning
vThe protocol version. 4 today.
typeThe message type. A closed set of thirteen.
kindThe shape of the interaction: request, response or event.
idA UUID identifying the interaction.
timestampISO 8601, when the message was created.
payloadThe 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

KindMeaningConstraint
requestStarts an interaction.Expects exactly one response, with the same id.
responseAnswers a request.Carries the same id as the request it answers.
eventA 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.

TypeKindDirectionPurpose
authrequestagent β†’ studioThe authentication handshake.
authresponsestudio β†’ agentThe outcome of the handshake.
commandrequeststudio β†’ agentA command to execute.
commandresponseagent β†’ studioThe outcome of a command.
agent_statuseventagent β†’ studioThe current task and the running total.
pingrequeststudio β†’ agentThe keepalive check.
pingresponseagent β†’ studioThe keepalive answer.
device_eventeventagent β†’ studioA device was attached or removed.
automation_eventeventagent β†’ studioA device's automation endpoint came up or went away.
device_listeventagent β†’ studioEvery device the bench currently holds.
device_setup_requestrequestagent β†’ studioThe bench asks the studio to put a driver on a device.
device_setup_requestresponsestudio β†’ agentWhether the setup chain started, and why not.
dashboard_authrequestdashboard β†’ studioThe dashboard's handshake.
dashboard_authresponsestudio β†’ dashboardThe outcome of the dashboard's handshake.
agent_listeventstudio β†’ dashboardA full snapshot of the agents.
agent_updateeventstudio β†’ dashboardOne agent changed.
device_updateeventstudio β†’ dashboardThe bench's device list changed.
dependency_manifesteventstudio β†’ agentThe dependency list the bench is checked against.
health_reporteventagent β†’ studioThe bench's health check result.
screen_frameeventagent β†’ studioOne 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 socket is authenticated, not encrypted

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.

taskIdWork state
A command idbusy
nullidle

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.

EventDirectionPayload
dashboard_authdashboard β†’ studio{ token }
dashboard_authstudio β†’ dashboard{ ok, data } or { ok, error }
agent_liststudio β†’ dashboard{ agents: [{ agentId, name, status, workState, … }] }
agent_updatestudio β†’ 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:

  1. 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.
  2. The agent executes them.
  3. The agent posts each outcome to POST /api/agent/commands/result, as a command response 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