# Exec and files

Run commands in a sandbox (sync, in the background, as an SSE stream, or in an interactive PTY) and move files in and out. All calls wake a paused sandbox automatically unless you pass `auto_resume=false`.

## Run a command (sync)

`POST /sandboxes/{sandbox_id}/exec` waits for the command to exit and returns `ExecResult`.

```ts
const r = await sandbox.exec({ command: "npm test", cwd: "/home/user/app", timeout_seconds: 300 });
console.log(r.exit_code, r.stdout, r.stderr, r.duration_ms);
```

```python
r = sandbox.exec(command="npm test", cwd="/home/user/app", timeout_seconds=300)
print(r.exit_code, r.stdout)
```

```bash
curl -sS -X POST https://api.task.cloud/v1/sandboxes/sbx_2fqxnt4bkq7m/exec \
  -H "Authorization: Bearer $TASKCLOUD_API_KEY" -H "Content-Type: application/json" \
  -d '{"command":"npm test","cwd":"/home/user/app","timeout_seconds":300}'
# {"command_id":"cmd_9fz2cq4kd7mw","exit_code":0,"stdout":"...","stderr":"","truncated":false,"duration_ms":8123}

taskcloud exec sbx_2fqxnt4bkq7m --cwd /home/user/app --timeout 300 -- npm test
```

Request fields:

| Field | Default | Notes |
| --- | --- | --- |
| `command` | – | run with `/bin/bash -lc` |
| `args` | – | program + arguments, executed without a shell (use instead of `command`) |
| `cwd` | `/home/user` | |
| `env` | – | extra variables for this command |
| `user` | `user` | `user` has passwordless sudo; `root` is allowed |
| `stdin` | – | written to stdin, then closed (sync and stream) |
| `timeout_seconds` | 60 | max 600; sync and stream only |
| `background` | `false` | return immediately, no time limit |
| `stream` | `false` | respond with SSE (same as `Accept: text/event-stream`) |

- A non-zero exit code is a normal `200` response, not an error.
- If the command exceeds `timeout_seconds`, it is killed and the API returns `408 exec_timeout` with partial output in `details`.
- Captured output is limited to 8 MiB per stream; `truncated: true` tells you it was cut. Use stream mode for large output.
- Non-UTF-8 output is returned base64-encoded with `encoding: "base64"`.
- At least 10 commands can run concurrently in one sandbox.

## Background commands

For dev servers, long builds and agents: `background: true` returns `202` with a `Command` right away. There is no time limit.

```ts
const server = await sandbox.exec({ command: "npm run dev -- --port 3000", cwd: "/home/user/app", background: true });
console.log(server.id); // cmd_...

// later
for await (const ev of server.stream()) {
  if (ev.type === "stdout") process.stdout.write(ev.data);
  if (ev.type === "exit") console.log("exited", ev.exit_code);
}
await server.kill({ signal: "SIGTERM" });
```

```python
server = sandbox.exec(command="npm run dev -- --port 3000", cwd="/home/user/app", background=True)
for ev in server.stream():
    if ev.type == "stdout":
        print(ev.data, end="")
server.kill(signal="SIGTERM")
```

```bash
taskcloud exec sbx_2fqxnt4bkq7m --background -- npm run dev -- --port 3000
taskcloud commands list sbx_2fqxnt4bkq7m
taskcloud commands logs sbx_2fqxnt4bkq7m cmd_9fz2cq4kd7mw --follow
taskcloud commands kill sbx_2fqxnt4bkq7m cmd_9fz2cq4kd7mw
```

Endpoints:

| Endpoint | Purpose |
| --- | --- |
| `GET /sandboxes/{sandbox_id}/commands` | recent commands (last 7 days), filter `state` |
| `GET /sandboxes/{sandbox_id}/commands/{command_id}` | state: `running`, `exited`, `killed`, `timed_out`, `lost` |
| `GET .../commands/{command_id}/stream` | SSE; replays earlier output (up to 8 MiB per stream); resume with `Last-Event-ID` |
| `POST .../commands/{command_id}/stdin` | `{data, encoding, eof}` |
| `POST .../commands/{command_id}/kill` | `{signal}`; default `SIGTERM` |

A running command counts as activity, so the sandbox will not auto-pause while it runs. For a service that should survive pause/resume and forks, a systemd unit is more robust than a background command.

## Streaming output (SSE)

Stream mode returns Server-Sent Events: `start`, `stdout`, `stderr`, `exit`, `error`. There is no output limit.

```ts
for await (const ev of sandbox.execStream({ command: "pip install -r requirements.txt" })) {
  if (ev.type === "stdout" || ev.type === "stderr") process.stdout.write(ev.data);
  if (ev.type === "exit") console.log("exit", ev.exit_code);
}
```

```python
for ev in sandbox.exec_stream(command="pip install -r requirements.txt"):
    if ev.type in ("stdout", "stderr"):
        print(ev.data, end="")
```

```bash
curl -sS -N -X POST https://api.task.cloud/v1/sandboxes/sbx_2fqxnt4bkq7m/exec \
  -H "Authorization: Bearer $TASKCLOUD_API_KEY" -H "Content-Type: application/json" \
  -H "Accept: text/event-stream" \
  -d '{"command":"pip install -r requirements.txt"}'
```

```
event: start
data: {"type":"start","command_id":"cmd_9fz2cq4kd7mw","pid":4211}

event: stdout
data: {"type":"stdout","command_id":"cmd_9fz2cq4kd7mw","data":"Collecting requests\n"}

event: exit
data: {"type":"exit","command_id":"cmd_9fz2cq4kd7mw","exit_code":0,"duration_ms":8123}
```

## Interactive terminal (PTY over WebSocket)

`POST /sandboxes/{sandbox_id}/pty` starts a pseudo-terminal and returns `websocket_url`, a signed URL valid for 60 s until first connect. It needs no `Authorization` header, so you can hand it to a browser terminal such as xterm.js.

Protocol:

- **Binary frames:** raw keystrokes (client → server) and raw terminal output (server → client).
- **Text frames (JSON):** client sends `{"type":"resize","cols":120,"rows":40}`, `{"type":"signal","signal":"SIGINT"}`, `{"type":"ping"}`; server sends `{"type":"ready","pty_id":"..."}`, `{"type":"exit","exit_code":0}`, `{"type":"pong"}`, `{"type":"error","error":{...}}`.
- After a drop, call `GET .../pty/{pty_id}` within 60 s for a fresh URL; up to 64 KiB of missed output is replayed.
- The session ends when the process exits or after `idle_timeout_seconds` (default 1800) without traffic. An open PTY prevents auto-pause.

```ts
const term = await sandbox.pty.connect({
  cols: 120,
  rows: 40,
  onData: (bytes) => process.stdout.write(bytes),
});
term.write("htop\r");
term.resize(160, 48);
term.close();
```

```bash
# Raw API
curl -sS -X POST https://api.task.cloud/v1/sandboxes/sbx_2fqxnt4bkq7m/pty \
  -H "Authorization: Bearer $TASKCLOUD_API_KEY" -H "Content-Type: application/json" \
  -d '{"cols":120,"rows":40}'
# {"id":"pty_7hc2zq5mxk3d","state":"waiting","websocket_url":"wss://api.task.cloud/v1/sandboxes/sbx_2fqxnt4bkq7m/pty/pty_7hc2zq5mxk3d/connect?token=pt_...","expires_at":"..."}

# Or simply
taskcloud shell sbx_2fqxnt4bkq7m
```

## Files

Paths are absolute, or relative to `/home/user`.

### Read and write

```ts
await sandbox.files.write("/home/user/app/.env", "PORT=3000\n", { mode: "0600" });
const text = await sandbox.files.readText("/home/user/app/package.json");
const bytes = await sandbox.files.read("/home/user/app/logo.png"); // Uint8Array
```

```python
sandbox.files.write("/home/user/app/.env", "PORT=3000\n", mode="0600")
text = sandbox.files.read_text("/home/user/app/package.json")
data = sandbox.files.read("/home/user/app/logo.png")  # bytes
```

```bash
# write (raw body)
curl -sS -X PUT "https://api.task.cloud/v1/sandboxes/sbx_2fqxnt4bkq7m/files/content?path=/home/user/app/.env&mode=0600" \
  -H "Authorization: Bearer $TASKCLOUD_API_KEY" -H "Content-Type: application/octet-stream" \
  --data-binary @.env

# read (raw bytes; supports Range)
curl -sS "https://api.task.cloud/v1/sandboxes/sbx_2fqxnt4bkq7m/files/content?path=/home/user/app/package.json" \
  -H "Authorization: Bearer $TASKCLOUD_API_KEY"
```

- `PUT .../files/content` creates parent directories by default (`create_parents=true`), accepts `mode` and `owner`, and supports `If-Match` with the file's `ETag` (`412 precondition_failed` on mismatch).
- `GET .../files/content` streams the file and supports `Range`.
- Up to 1 GiB per request; larger files return `413 payload_too_large`. Use signed URLs for those.

### List, stat, mkdir, move, delete

```ts
const entries = await sandbox.files.list("/home/user/app", { recursive: true });
const info = await sandbox.files.stat("/home/user/app/package.json");
await sandbox.files.mkdir("/home/user/data");
await sandbox.files.move("/home/user/a.txt", "/home/user/data/a.txt");
await sandbox.files.remove("/home/user/tmp", { recursive: true });
```

| Endpoint | Notes |
| --- | --- |
| `GET /sandboxes/{sandbox_id}/files?path=` | `recursive`, `max_depth` (default 8), paginated |
| `GET .../files/stat?path=` | `FileEntry`: `name`, `path`, `type`, `size`, `mode`, `owner`, `modified_at`, `etag` |
| `POST .../files/mkdir` | `{path, parents, mode}` |
| `POST .../files/move` | `{from, to, overwrite}` |
| `DELETE /sandboxes/{sandbox_id}/files?path=` | `recursive=true` for non-empty directories |

### Upload and download many files

```ts
await sandbox.files.upload("/home/user/app", {
  "src/index.ts": "console.log('hi')\n",   // relative name -> string, Uint8Array or Blob
  "package.json": pkgJson,
});
const archive = await sandbox.files.downloadArchive("/home/user/app/dist", { format: "tar.gz" });
```

```python
sandbox.files.upload("/home/user/app", [("src/index.ts", b"print('hi')\n")])
archive = sandbox.files.download_archive("/home/user/app/dist", format="tar.gz")
```

```bash
# multipart upload; filenames may include sub-directories
curl -sS -X POST https://api.task.cloud/v1/sandboxes/sbx_2fqxnt4bkq7m/files/upload \
  -H "Authorization: Bearer $TASKCLOUD_API_KEY" \
  -F path=/home/user/app -F extract=true -F files=@repo.tar.gz

# directory as an archive (tar.gz, tar or zip)
curl -sS -o dist.tar.gz "https://api.task.cloud/v1/sandboxes/sbx_2fqxnt4bkq7m/files/download?path=/home/user/app/dist&format=tar.gz" \
  -H "Authorization: Bearer $TASKCLOUD_API_KEY"

taskcloud cp -r ./app sbx_2fqxnt4bkq7m:/home/user/app
taskcloud cp sbx_2fqxnt4bkq7m:/home/user/app/report.pdf ./report.pdf
```

### Signed URLs (large files, browsers)

Pre-signed URLs work without an API key, for a single `PUT` (upload) or `GET` (download) of up to 10 GiB. Default lifetime 900 s, max 3600 s.

```ts
const up = await sandbox.files.uploadUrl({ path: "/home/user/data/dataset.parquet", expires_in_seconds: 900 });
// up.method === "PUT"; give up.url to your user's browser or a worker
const down = await sandbox.files.downloadUrl({ path: "/home/user/app/out.zip" });
```

```python
up = sandbox.files.upload_url(path="/home/user/data/dataset.parquet")
down = sandbox.files.download_url(path="/home/user/app/out.zip")
```

### Watch for changes

`GET .../files/watch?path=` streams `FileEvent`s (`created`, `modified`, `deleted`, `renamed`) as SSE. Options: `recursive` (default `true`), `include_hidden` (default `false`). A watch does not keep the sandbox awake; it stays open across pause/resume and delivers events again after the VM wakes.

```ts
for await (const ev of sandbox.files.watch("/home/user/app/src", { recursive: true })) {
  console.log(ev.type, ev.path);
}
```

```python
for ev in sandbox.files.watch("/home/user/app/src", recursive=True):
    print(ev.type, ev.path)
```

```bash
taskcloud files watch sbx_2fqxnt4bkq7m /home/user/app/src
```

## Errors you will see

| Status | `code` | Retry? | Meaning |
| --- | --- | --- | --- |
| 404 | `not_found` | no | sandbox, command or path does not exist |
| 408 | `exec_timeout` | no | sync command killed after `timeout_seconds` |
| 409 | `sandbox_paused` | no | only with `auto_resume=false` |
| 409 | `sandbox_busy` | yes | transient; retry after `Retry-After` |
| 413 | `payload_too_large` | no | use signed URLs |
| 502 | `sandbox_unreachable` | per `retryable` | guest did not answer; the command may have run |

Always branch on `error.code` and `error.retryable`:

```ts
import { ApiError } from "@taskcloud/sdk";
try {
  await sandbox.exec({ command: "make build", timeout_seconds: 600 });
} catch (e) {
  if (e instanceof ApiError && e.retryable) { /* back off and retry */ }
  else throw e;
}
```

## Related

- [Networking](networking.md): expose the dev server you just started
- [Lifecycle](../concepts/lifecycle.md)
- [Limits](../limits.md)
- [API reference](../api-reference.md)
