Protocol Used by Linux cowork-vm-service: Unix Socket & JSON Framing Guide
The Linux cowork-vm-service daemon communicates via a Unix-domain socket using a length-prefixed JSON protocol, mirroring the Windows named-pipe implementation but adapting to Linux filesystem conventions.
The cowork-vm-service is a core component of the Claude Desktop Linux port (aaddrick/claude-desktop-debian), enabling the Electron frontend to spawn and manage isolated VMs. Understanding the protocol used by Linux cowork-vm-service is essential for debugging connection issues or building alternative clients.
Transport Layer: Unix-Domain Socket Location
Unlike the Windows version that uses named pipes, the Linux implementation binds to a filesystem socket.
Socket Path Configuration
The daemon creates its listening socket at:
"$XDG_RUNTIME_DIR/cowork-vm-service.sock"
If the XDG_RUNTIME_DIR environment variable is unset, the service falls back to:
"/tmp/cowork-vm-service.sock"
Source: lines 20-22 of scripts/cowork-vm-service.js
Message Framing Protocol
To handle JSON message boundaries over the stream-oriented socket, the protocol implements explicit length-prefix framing.
4-Byte Big-Endian Length Prefix
Every message is prefixed with a 4-byte unsigned integer in big-endian format, indicating the length of the subsequent UTF-8 JSON payload.
The writeMessage() function implements this encoding:
function writeMessage(sock, msg) {
const json = JSON.stringify(msg);
const buf = Buffer.from(json, 'utf8');
const len = Buffer.alloc(4);
len.writeUInt32BE(buf.length, 0);
sock.write(Buffer.concat([len, buf]));
}
Source: lines 107-112 of scripts/cowork-vm-service.js
JSON Message Specifications
The protocol supports three distinct message types: requests, responses, and asynchronous events.
Request Format
Clients send method invocations with a unique identifier for correlation:
{
"method": "methodName",
"params": { },
"id": 1
}
The id field is echoed back in the response to allow the client to match asynchronous replies.
Response Format
The daemon replies with either a success or error payload:
Success:
{
"success": true,
"result": { },
"id": 1
}
Error:
{
"success": false,
"error": "Error message",
"id": 1
}
Event Notifications
Asynchronous events broadcast state changes without requiring a request:
{
"type": "stdout",
"data": "..."
}
Supported event types include stdout, stderr, exit, error, networkStatus, and apiReachability.
The complete protocol definition is documented in the comment block at the top of the daemon source file.
Server Lifecycle and Connection Management
The daemon implements robust lifecycle management to handle crashes and restarts.
Startup Behavior
On initialization, the service:
- Removes any stale socket file to prevent "address already in use" errors
- Creates a
net.createServerinstance - Begins listening on the configured socket path
Message Buffering
The server maintains a per-connection buffer to handle partial reads. The parseMessage() function extracts complete messages from the buffer only when the 4-byte length header and full payload are available.
Error Handling
Malformed messages trigger error logging followed by a buffer reset, preventing corrupted data from desynchronizing the connection. The daemon continues processing subsequent valid messages.
Implementation Examples
Node.js Client Example
This complete client demonstrates connecting to the daemon and sending a ping request:
const net = require('net');
const fs = require('fs');
const path = require('path');
// Build the same length‑prefixed writer the daemon uses
function writeMessage(sock, msg) {
const json = JSON.stringify(msg);
const buf = Buffer.from(json, 'utf8');
const len = Buffer.alloc(4);
len.writeUInt32BE(buf.length, 0);
sock.write(Buffer.concat([len, buf]));
}
// Parser – identical to daemon's parseMessage
function parseMessage(buffer) {
if (buffer.length < 4) return null;
const len = buffer.readUInt32BE(0);
if (buffer.length < 4 + len) return null;
const json = buffer.subarray(4, 4 + len).toString('utf8');
const remaining = Buffer.from(buffer.subarray(4 + len));
return { message: JSON.parse(json), remaining };
}
// Connect to the daemon socket
const socketPath = (process.env.XDG_RUNTIME_DIR || '/tmp') + '/cowork-vm-service.sock';
const client = net.createConnection(socketPath, () => {
console.log('connected');
writeMessage(client, { method: 'ping', params: {}, id: 1 });
});
let buf = Buffer.alloc(0);
client.on('data', (data) => {
buf = Buffer.concat([buf, data]);
let parsed;
while ((parsed = parseMessage(buf))) {
buf = parsed.remaining;
console.log('reply:', parsed.message);
// → { success:true, result:{…}, id:1 }
}
});
client.on('error', console.error);
Bash Client with socat
For shell scripting, use socat to handle the socket connection and xxd for binary length encoding:
#!/usr/bin/env bash
SOCK="${XDG_RUNTIME_DIR:-/tmp}/cowork-vm-service.sock"
# Build a JSON request and prepend length (big‑endian)
REQ='{"method":"ping","params":{},"id":42}'
LEN=$(printf '%04x' $(printf '%s' "$REQ" | wc -c) | xxd -r -p)
# Send request and read raw reply
{
printf "$LEN"
printf '%s' "$REQ"
} | socat - UNIX-CONNECT:"$SOCK" | {
# Read 4‑byte length
read -r -n4 LEN_RAW
LEN=$((0x$(echo -n "$LEN_RAW" | xxd -p)))
# Read the JSON payload
read -r -n"$LEN" JSON
echo "Response: $JSON"
}
Both examples follow the same 4‑byte length prefix + JSON framing that the daemon expects.
Key Source Files
| File | Role | Link |
|---|---|---|
scripts/cowork-vm-service.js |
Main daemon implementation – defines transport, framing, request handling, and backend selection. | scripts/cowork‑vm‑service.js |
docs/learnings/cowork-vm-daemon.md |
Narrative description of the daemon’s design, debugging steps, and socket lifecycle. | docs/learnings/cowork‑vm‑daemon.md |
docs/cowork-linux-handover.md |
High‑level hand‑over doc that lists the service, transport, and status matrix. | docs/cowork‑linux‑handover.md |
tests/cowork-path-translation.bats |
Test suite exercising guest‑path translation – indirectly validates that the daemon can be started and the socket created. | tests/cowork‑path‑translation.bats |
These files together give a complete picture of the protocol, its implementation, and how clients should communicate with the Linux cowork‑vm‑service.
Summary
- The Linux
cowork-vm-serviceuses a Unix-domain socket located at$XDG_RUNTIME_DIR/cowork-vm-service.sock(falling back to/tmp). - Communication follows a length-prefixed JSON protocol: each message is prefixed with a 4-byte big-endian integer indicating the UTF-8 JSON payload length.
- The protocol supports three message types: synchronous requests/responses (with numeric IDs for correlation) and asynchronous events (stdout, stderr, exit, etc.).
- The
writeMessage()andparseMessage()functions inscripts/cowork-vm-service.jsimplement the framing logic at lines 107-112 and corresponding read logic. - Clients can interact with the daemon using Node.js
netmodules or shell tools likesocatby respecting the 4-byte length prefix.
Frequently Asked Questions
What transport protocol does Linux cowork-vm-service use?
The daemon uses a Unix-domain socket (AF_UNIX) rather than TCP/IP or named pipes. This provides secure, filesystem-based inter-process communication between the Claude Desktop Electron frontend and the VM management daemon, with the socket file created at $XDG_RUNTIME_DIR/cowork-vm-service.sock or /tmp as a fallback.
How is message framing implemented in the protocol?
Messages use 4-byte big-endian length-prefix framing. Each JSON payload is preceded by a 32-bit unsigned integer (in network byte order) indicating the byte length of the following UTF-8 data. This allows the parseMessage() function to correctly reconstruct boundaries from the stream-oriented socket, even when multiple messages arrive in a single read or are split across packets.
What is the difference between the Linux and Windows implementations?
Both implementations share the identical JSON message schema and length-prefix framing, ensuring protocol compatibility. The only difference is the transport layer: Windows uses named pipes (\\.\pipe\...), while Linux uses Unix-domain sockets created on the filesystem. The writeMessage() and request/response handling logic remains consistent across platforms.
Where is the socket file created by default?
By default, the socket is created at "$XDG_RUNTIME_DIR/cowork-vm-service.sock", respecting the XDG Base Directory specification for runtime files. If the XDG_RUNTIME_DIR environment variable is not set—which commonly occurs in minimal environments or SSH sessions—the daemon falls back to creating the socket at /tmp/cowork-vm-service.sock to ensure operational continuity.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →