MetorialDocs

Connection wire protocol

Connect to machines from any language over WebSockets and HTTPS.

If you use our SDKs or CLI, you don't need to read this. The SDKs handle the wire protocol for you. If you want to manually access a machine's file system, connect to its shell, or run a command, this page explains the message formats and connection details.

What you'll learn

  • How to get an access token and connection URLs
  • The message formats for commands, interactive shells, and files
  • Close codes and error handling

Before you start

Get an access token

Request a token for the kind of access you need: shell, files, or ports. Tokens last 3600 seconds by default and up to 86400.

curl -X POST https://api.metorial.com/sandbox/machines/$MACHINE/access/shell \
  -H "Authorization: Bearer $METORIAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"ttl_seconds": 3600}'
{
  "object": "sandbox.machine.access",
  "token": "…",
  "scope": "shell",
  "urls": {
    "shell": "wss://…",
    "exec": "wss://…",
    "files": "wss://…",
    "ports": {}
  },
  "expires_at": "2026-10-16T15:00:00.000Z"
}

A shell token works for both the shell and exec URLs. For ports, pass "ports": [3000] or "ports": "*" in the body, and urls.ports maps each port to its URL.

Authenticate a WebSocket

Send the token as a WebSocket subprotocol, next to the protocol name. Headers and query parameters aren't accepted. Browsers can connect this way, since they can't set WebSocket headers.

let socket = new WebSocket(access.urls.exec, [
  'metorial-sandbox-v1',
  `metorial-sandbox-token.${access.token}`
]);
socket.binaryType = 'arraybuffer';

Call a port

Ports are plain HTTPS. Send the token in the Metorial-Sandbox-Token header. Public ports need no token.

curl -H "Metorial-Sandbox-Token: $TOKEN" "$PORT_URL/health"

Multiplexed framing

The exec and files protocols run many requests over one socket. Every request has a client-chosen id, and every reply carries it.

  • Text frames are JSON objects with an id.
  • Binary frames start with one byte holding the length of the id, then the id in UTF-8, then the payload.
[id length: 1 byte][id: UTF-8][payload]

Exec

Use the urls.exec socket to run commands and stream their output. The SDKs' command() and spawn() use it.

Run a command

{
  "id": "r1",
  "op": "run",
  "v": 1,
  "command": ["/bin/sh", "-c", "npm test"],
  "cwd": "/app",
  "env": { "CI": "true" },
  "timeoutMs": 300000
}

command is an argv array. cwd, env, and timeoutMs are optional. Set "stdin": true to keep standard input open for writing.

Messages from the client

MessagePurpose
Binary frame [id][bytes]Writes to the command's stdin
{ "id", "type": "stdin_eof" }Closes stdin
{ "id", "type": "signal", "signal": "TERM" }Sends a signal. Use names without SIG.

Messages from the server

MessageMeaning
{ "id", "type": "started", "pid" }The process started
Binary frame [id][stream][bytes]Output. The stream byte is 1 for stdout and 2 for stderr.
{ "id", "type": "exit", "code", "signal", "timedOut" }The process ended
{ "id", "type": "error", "error": { "code", "message", "retryable" } }The request failed

Interactive shell

Use the urls.shell socket for a terminal session. The CLI's shell command uses it. Each socket carries one session.

Open a session

Send open as the first message:

{ "type": "open", "v": 1, "tty": true, "cols": 120, "rows": 40, "cwd": "/app" }

command (an argv array), cwd, and env are optional. Leave out command for an interactive shell. cols and rows go from 1 to 1000 and only apply when tty is true.

The server answers with { "type": "session_init", "sessionId": "shs_…", "offset": 0 }.

Streams

  • Client binary frames are stdin.
  • With tty: true, server binary frames are raw terminal output.
  • Without a TTY, the first byte of each server binary frame is 0x01 for stdout or 0x02 for stderr.

Control messages from the client are { "type": "resize", "cols", "rows" }, { "type": "signal", "signal": "INT" }, and { "type": "stdin_eof" }. When the process ends, the server sends { "type": "exit", "code", "signal" }.

Resume after a dropped connection

Track how many output bytes you've received. If the socket drops without a normal close, open a new socket and attach to the session instead of opening a new one:

{ "type": "attach", "v": 1, "sessionId": "shs_…", "offset": 18342 }

The server replays output from offset. If some of it is no longer buffered, it first sends { "type": "output_gap", "from", "to" }. Closing the socket with code 1000 ends the session for good.

Files

Use the urls.files socket to read and write files. Requests use the multiplexed framing, and file content travels in binary frames of up to 64 KiB. All paths are absolute.

RequestReplies
{ "id", "op": "stat", "path", "followSymlinks" }result with entry
{ "id", "op": "list", "path", "recursive", "maxDepth", "limit" }One or more entries messages, then result with count and truncated
{ "id", "op": "read", "path", "offset", "length" }start with size, mtime, and offset, then binary chunks, then result with bytes
{ "id", "op": "write", "path", "overwrite", "parents", "mode": "0644" }result with bytes and sha256
{ "id", "op": "mkdir", "path", "parents", "mode" }result with created
{ "id", "op": "move", "from", "to", "overwrite" }result
{ "id", "op": "delete", "path", "recursive", "missingOk" }result with deleted

Write a file

After the write request, send the content as binary frames, then finish with the SHA-256 of the whole content. The server rejects the write with checksum_mismatch if the hashes differ.

{ "id": "w1", "type": "end", "sha256": "9f86d08…" }

write also accepts append, sync, and the expected total size.

File entries

stat and list return entries with type (file, directory, symlink, or other), size, mode, mtime in milliseconds, and target for symlinks. Non-recursive listings set name. Recursive listings set path, relative to the listed directory.

Cancel a request

Send { "id": "c1", "op": "cancel", "target": "r1" } to cancel request r1, or { "id": "w1", "type": "abort" } to abort an upload in progress.

Errors

{
  "id": "r1",
  "type": "error",
  "error": { "code": "not_found", "message": "…", "path": "/app/x" }
}
CodeMeaning
not_foundThe path doesn't exist
already_existsThe destination exists and overwrite is off
not_a_directory, not_a_file, is_a_directoryThe path has the wrong type for the operation
not_emptyThe directory isn't empty and recursive is off
checksum_mismatchThe uploaded content doesn't match its sha256
unknown_opThe server doesn't support the operation
io_errorThe file system returned an error
connection_interruptedThe connection dropped. Retry the request.

Connection notices

Idle sockets close after the machine's connection idle timeout. Before that, the server sends notices:

{ "type": "connection_notice", "notice": "idle_warning", "idleInSeconds": 30 }

notice is idle_warning, idle, or closing, and closing can include a reason.

Close codes

CodeMeaningWhat to do
1000Normal close. Reason idle_timeout means the socket was idle.Reconnect when needed
1012The machine paused, is restarting, or terminated. The reason says which.Start the machine, then reconnect
4401The token is invalid or expiredRequest a new token and reconnect
4404The machine or session wasn't foundCheck the ID
4410The machine was terminated, or the shell session endedDon't retry
4429Too many requestsBack off and retry
4503The machine is unavailable. Reason machine_failed means it failed.Retry later, or check the machine's failure

The shell socket adds these:

CodeMeaning
4000The session was detached
4400The open or attach message was invalid
4409Another socket is attached to the session
4500The session failed to start
4503Too many sessions are open on the machine