# Exec

> Run commands in a sandbox over HTTP or a WebSocket, with sessions that keep running when you disconnect.

Every command runs as a session. A session belongs to the sandbox, not to your connection: if the client goes away, the process keeps running and you can attach to it again.

Commands run as root with the [environment and secrets](https://nanite.run/docs/environment-and-secrets.md) of the sandbox. Exec wakes a sandbox that is not running.

## Run a command over HTTP

```http
POST /v1/sandboxes/{name}/exec
```

| Query   | About                                                                                      |
| ------- | ------------------------------------------------------------------------------------------ |
| `cmd`   | One argument of the command. Repeat it for each one: `cmd=ls&cmd=-la`. Defaults to `bash`. |
| `path`  | The program to run, in place of the first `cmd`.                                           |
| `env`   | `KEY=VALUE`, repeatable. Set over the environment of the sandbox.                          |
| `dir`   | The working directory.                                                                     |
| `stdin` | `true` to send the request body as stdin, up to 10 MiB.                                    |

```bash
curl -X POST "$API/agent-1/exec" -H "$AUTH" --output - \
  --url-query cmd=sh --url-query cmd=-c --url-query "cmd=echo hi; ls /nope" --url-query dir=/root
```

`--url-query` (curl 7.87 and later) encodes each value for you.

The response is `application/octet-stream` and streams while the command runs. Each chunk starts with one byte that names its stream:

| Byte | Stream                                          |
| ---- | ----------------------------------------------- |
| `1`  | stdout                                          |
| `2`  | stderr                                          |
| `3`  | exit: one more byte, the exit code, and the end |

Pipe a file in as stdin:

```bash
curl -X POST "$API/agent-1/exec?cmd=wc&cmd=-l&stdin=true" -H "$AUTH" --data-binary @notes.txt --output -
```

The HTTP form has no terminal. For an interactive shell, use the WebSocket.

## Run a command over a WebSocket

```http
GET /v1/sandboxes/{name}/exec    (Upgrade: websocket)
```

Takes the same query as the HTTP form, plus:

| Query                      | About                                                                                                                                                 |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tty`                      | `true` for a terminal.                                                                                                                                |
| `rows`, `cols`             | The terminal size.                                                                                                                                    |
| `max_run_after_disconnect` | How long the process may run once no client is attached: seconds, or a number with `ms`, `s`, `m` or `h`. Without it, the process runs until it ends. |

The first message is text:

```json
{
  "type": "session_info",
  "session_id": 7,
  "command": "bash",
  "created": "2026-10-31T09:21:04Z",
  "cols": 120,
  "rows": 32,
  "is_owner": true,
  "tty": true,
  "output_offset": 0
}
```

Without a terminal, binary messages carry a stream byte and data, both ways:

| Byte | Direction        | Stream                               |
| ---- | ---------------- | ------------------------------------ |
| `0`  | to the sandbox   | stdin                                |
| `4`  | to the sandbox   | end of stdin                         |
| `1`  | from the sandbox | stdout                               |
| `2`  | from the sandbox | stderr                               |
| `3`  | from the sandbox | exit, with the exit code as one byte |

With `tty=true`, binary messages are raw terminal bytes in both directions, and the exit comes as text: `{"type": "exit", "exit_code": 0}`.

Text messages you can send:

```json
{ "type": "resize", "cols": 160, "rows": 48 }
{ "type": "signal", "signal": "SIGTERM" }
```

The socket carries `SIGTERM` only. To send another signal, [kill the session](#kill-a-session).

Text messages you may receive, besides `session_info` and `exit`:

| Type     | About                                                                                                                                        |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `error`  | `{ "type": "error", "message": "..." }`                                                                                                      |
| `gap`    | `{ "type": "gap", "bytes": 4096, "output_offset": 1052672 }`. Output was dropped before you attached.                                        |
| `behind` | `{ "type": "behind", "output_offset": ... }`. The client read too slowly; the socket closes with code `1013`. Attach again from that offset. |

A minimal terminal client in Node, with the `ws` package:

```js
import WebSocket from "ws";

const ws = new WebSocket("wss://api.nanite.run/v1/sandboxes/agent-1/exec?tty=true&cols=120&rows=32", {
  headers: { Authorization: `Bearer ${process.env.NANITE_KEY}` },
});
ws.on("message", (data, binary) => {
  if (binary) process.stdout.write(data);
  else console.log(JSON.parse(data.toString()));
});
ws.on("open", () => ws.send(Buffer.from("uname -a\n")));
```

Browsers cannot set headers on a WebSocket, so open sockets from your server.

## List sessions

```http
GET /v1/sandboxes/{name}/exec
```

```json
{
  "sessions": [
    {
      "id": 7,
      "command": "npm run build",
      "workdir": "/root/app",
      "tty": false,
      "created": "2026-10-31T09:21:04Z",
      "is_active": true,
      "last_activity": "2026-10-31T09:22:40Z",
      "bytes_per_second": 812
    }
  ],
  "count": 1
}
```

A sandbox that is not running has no sessions.

## Attach to a session

```http
GET /v1/sandboxes/{name}/exec/{session_id}    (Upgrade: websocket)
```

Attaches to a session of a running sandbox and speaks the same messages as a new one. Each session keeps the last 1 MiB of its output; pass `output_offset`, a byte offset from `session_info` or from your own count, to replay from where you left off. If part of what you asked for is gone, a `gap` message says how much. An unknown session answers `404`, and so does any session of a sandbox that is not running.

## Kill a session

```http
POST /v1/sandboxes/{name}/exec/{session_id}/kill
```

| Query     | About                                             |
| --------- | ------------------------------------------------- |
| `signal`  | The signal to send first. Defaults to `SIGTERM`.  |
| `timeout` | Seconds to wait before `SIGKILL`. Defaults to 10. |

The answer is NDJSON, one event per line, ending with `complete` or `error`:

```json
{"type":"signal","message":"Signaling SIGTERM to the processes of session 7","signal":"SIGTERM","pid":412}
{"type":"exited","message":"Process exited"}
{"type":"complete","exit_code":143}
```

Other events are `timeout` (the process outlived the timeout) and `killed` (it was sent `SIGKILL`).
