# Connection wire protocol
URL: https://metorial.com/docs/build/sandboxes/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.

<PageIntro>
  <Learn>
    * How to get an access token and connection URLs
    * The message formats for commands, interactive shells, and files
    * Close codes and error handling
  </Learn>

  <Reading title="Before you start">
    * An [API key](/docs/build/api)
    * [Connect with the SDK](/docs/build/sandboxes/connect-sdk), which shows what each protocol does
  </Reading>
</PageIntro>

## Get an access token [#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.

```bash
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}'
```

```json
{
  "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 [#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.

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

### Call a port [#call-a-port]

Ports are plain HTTPS. Send the token in the `Metorial-Sandbox-Token` header. [Public ports](/docs/build/sandboxes/networking#make-a-port-public) need no token.

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

## Multiplexed framing [#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.

```text
[id length: 1 byte][id: UTF-8][payload]
```

## Exec [#exec]

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

### Run a command [#run-a-command]

```json
{
  "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 [#messages-from-the-client]

| Message                                        | Purpose                                  |
| ---------------------------------------------- | ---------------------------------------- |
| 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 [#messages-from-the-server]

| Message                                                                  | Meaning                                                       |
| ------------------------------------------------------------------------ | ------------------------------------------------------------- |
| `{ "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 [#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 [#open-a-session]

Send `open` as the first message:

```json
{ "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 [#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 [#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:

```json
{ "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 [#files]

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

| Request                                                                   | Replies                                                                                    |
| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `{ "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 [#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.

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

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

### File entries [#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 [#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 [#errors]

```json
{
  "id": "r1",
  "type": "error",
  "error": { "code": "not_found", "message": "…", "path": "/app/x" }
}
```

| Code                                              | Meaning                                          |
| ------------------------------------------------- | ------------------------------------------------ |
| `not_found`                                       | The path doesn't exist                           |
| `already_exists`                                  | The destination exists and `overwrite` is off    |
| `not_a_directory`, `not_a_file`, `is_a_directory` | The path has the wrong type for the operation    |
| `not_empty`                                       | The directory isn't empty and `recursive` is off |
| `checksum_mismatch`                               | The uploaded content doesn't match its `sha256`  |
| `unknown_op`                                      | The server doesn't support the operation         |
| `io_error`                                        | The file system returned an error                |
| `connection_interrupted`                          | The connection dropped. Retry the request.       |

## Connection notices [#connection-notices]

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

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

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

## Close codes [#close-codes]

| Code   | Meaning                                                                  | What to do                                    |
| ------ | ------------------------------------------------------------------------ | --------------------------------------------- |
| `1000` | Normal close. Reason `idle_timeout` means the socket was idle.           | Reconnect when needed                         |
| `1012` | The machine paused, is restarting, or terminated. The reason says which. | Start the machine, then reconnect             |
| `4401` | The token is invalid or expired                                          | Request a new token and reconnect             |
| `4404` | The machine or session wasn't found                                      | Check the ID                                  |
| `4410` | The machine was terminated, or the shell session ended                   | Don't retry                                   |
| `4429` | Too many requests                                                        | Back off and retry                            |
| `4503` | The machine is unavailable. Reason `machine_failed` means it failed.     | Retry later, or check the machine's `failure` |

The shell socket adds these:

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