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.

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);
r = sandbox.exec(command="npm test", cwd="/home/user/app", timeout_seconds=300)
print(r.exit_code, r.stdout)
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.

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" });
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")
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.

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);
}
for ev in sandbox.exec_stream(command="pip install -r requirements.txt"):
    if ev.type in ("stdout", "stderr"):
        print(ev.data, end="")
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.
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();
# 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#

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
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
# 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#

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#

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" });
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")
# 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.

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" });
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 FileEvents (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.

for await (const ev of sandbox.files.watch("/home/user/app/src", { recursive: true })) {
  console.log(ev.type, ev.path);
}
for ev in sandbox.files.watch("/home/user/app/src", recursive=True):
    print(ev.type, ev.path)
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:

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;
}