# Use Sandboxes from Python or TypeScript

> A small helper for each language that creates a sandbox, runs commands, moves files, forks it, and deletes it. No SDK to install.

URL: https://prized.dev/docs/sandboxes-python-typescript

The [Sandbox API](https://prized.dev/docs/sandboxes-api) is plain HTTPS with a bearer key, so one short file is enough: the Python helper uses only the standard library, and the TypeScript one only `fetch`. Copy one into your project and replace the demo at its end with your own steps. From a shell, the [CLI](https://prized.dev/docs/cli#sandboxes) does the same.

## 1. Export an API key [#1-export-an-api-key]

Make a key under **Dashboard → Settings → API keys** with **Full access**, or **Custom** with **Sandboxes: Write** ([Open Sandboxes](https://prized.dev/onboarding/sandboxes) also makes one). The helpers read it from `PRIZED_API_KEY`; never paste a key into code.

```bash
export PRIZED_API_KEY=dcp_…
```

## 2. Save a helper [#2-save-a-helper]

**Python**

```python title="sandbox.py"
import json, os, time, urllib.error, urllib.parse, urllib.request

API = "https://api.prized.dev/api/v1"
KEY = os.environ["PRIZED_API_KEY"]  # never hardcode a key
FREE = "That is a free-tier limit (https://prized.dev/docs/sandboxes#free-tier); a top-up or a plan lifts it."

def call(method, url, body=None, data=None):
    headers = {"Authorization": f"Bearer {KEY}", "Content-Type": "application/octet-stream"}
    if body is not None:
        data, headers["Content-Type"] = json.dumps(body).encode(), "application/json"
    try:
        with urllib.request.urlopen(urllib.request.Request(url, data, headers, method=method), timeout=3660) as r:
            out = r.read()
            return json.loads(out) if r.headers.get_content_type() == "application/json" else out
    except urllib.error.HTTPError as e:
        out = e.read()
        err = json.loads(out) if out.startswith(b"{") else {"error": out[:200]}
        hint = FREE if err.get("tier") == "free" else ""
        e.msg = f"{err} {hint}"  # str(e) is now "HTTP Error 429: {'error': ...} That is a free-tier limit ..."
        raise

class Sandbox:
    def __init__(self, id):
        self.id = id

    @classmethod
    def create(cls, template="prized-base-v1"):
        body = {"template": template, "resources": {"vcpu": 2, "memoryMb": 4096}, "maxRuntimeMin": 60}
        return cls(call("POST", f"{API}/sandboxes", body)["sandbox"]["id"])

    def wait(self):  # until running and taking commands
        while (sbx := call("GET", f"{API}/sandboxes/{self.id}")["sandbox"])["state"] == "starting":
            time.sleep(1)
        if sbx["state"] != "running":
            raise RuntimeError(f"{self.id} is {sbx['state']}: {sbx['errorReason']}")
        self.toolbox = sbx["toolboxBase"]
        self.exec("true", retry=(409, 429, 502, 503))  # a no-op, so retrying even a 502 is safe
        return self

    def exec(self, command, retry=(409, 429), **opts):  # opts: cwd, env, timeout
        for i in range(6):
            try:
                return call("POST", f"{self.toolbox}/process/execute", {"command": command, **opts})
            except urllib.error.HTTPError as e:  # 409 and 429 come before the command runs
                if e.code not in retry or i == 5:
                    raise
                time.sleep(i + 1)

    def write(self, path, data: bytes):
        call("POST", f"{self.toolbox}/files/upload?path={urllib.parse.quote(path)}", data=data)

    def read(self, path) -> bytes:
        return call("GET", f"{self.toolbox}/files/download?path={urllib.parse.quote(path)}")

    def fork(self, count):
        out = call("POST", f"{API}/sandboxes/{self.id}/fork", {"count": count})
        return [Sandbox(s["id"]) for s in out["sandboxes"]]

    def delete(self):
        call("DELETE", f"{API}/sandboxes/{self.id}")

sbx, kids = Sandbox.create(), []
try:
    print(sbx.wait().exec("python3 --version")["stdout"], end="")
    sbx.write("/home/prized/work/hello.txt", b"hello from Python\n")
    print(sbx.read("/home/prized/work/hello.txt").decode(), end="")
    kids = sbx.fork(2)  # two live copies, hello.txt included
    for kid in kids:
        print(kid.id, kid.wait().exec("cat hello.txt")["stdout"], end="")
finally:
    for s in [sbx, *kids]:
        try:
            s.delete()
            print("deleted", s.id)
        except Exception as e:  # try the rest; maxRuntimeMin removes this one anyway
            print("could not delete", s.id, e)
```

**TypeScript**

```ts title="sandbox.mts"
const API = "https://api.prized.dev/api/v1";
const KEY = process.env.PRIZED_API_KEY; // never hardcode a key
if (!KEY) throw new Error("Set PRIZED_API_KEY first");
const FREE = "That is a free-tier limit (https://prized.dev/docs/sandboxes#free-tier); a top-up or a plan lifts it.";
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

async function call(method: string, url: string, body?: unknown, data?: BodyInit): Promise<any> {
  const headers: Record<string, string> = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/octet-stream" };
  if (body !== undefined) [data, headers["Content-Type"]] = [JSON.stringify(body), "application/json"];
  const res = await fetch(url, { method, headers, body: data });
  const json = res.headers.get("content-type")?.startsWith("application/json");
  if (res.ok) return json ? res.json() : new Uint8Array(await res.arrayBuffer());
  const err = json ? await res.json() : { error: (await res.text()).slice(0, 200) };
  const hint = err.tier === "free" ? FREE : "";
  throw Object.assign(new Error(`${res.status} ${JSON.stringify(err)} ${hint}`), { status: res.status });
}

class Sandbox {
  id: string;
  toolbox = "";
  constructor(id: string) { this.id = id; }

  static async create(template = "prized-base-v1") {
    const body = { template, resources: { vcpu: 2, memoryMb: 4096 }, maxRuntimeMin: 60 };
    return new Sandbox((await call("POST", `${API}/sandboxes`, body)).sandbox.id);
  }

  async wait() { // until running and taking commands
    let s;
    while ((s = (await call("GET", `${API}/sandboxes/${this.id}`)).sandbox).state === "starting") await sleep(1000);
    if (s.state !== "running") throw new Error(`${this.id} is ${s.state}: ${s.errorReason}`);
    this.toolbox = s.toolboxBase;
    await this.exec("true", {}, [409, 429, 502, 503]); // a no-op, so retrying even a 502 is safe
    return this;
  }

  async exec(command: string, opts: { cwd?: string; env?: Record<string, string>; timeout?: number } = {}, retry = [409, 429]): Promise<any> {
    for (let i = 0; ; i++) {
      try {
        return await call("POST", `${this.toolbox}/process/execute`, { command, ...opts });
      } catch (e: any) { // 409 and 429 come before the command runs
        if (!retry.includes(e.status) || i >= 5) throw e;
        await sleep(1000 * (i + 1));
      }
    }
  }

  write(path: string, data: BodyInit) {
    return call("POST", `${this.toolbox}/files/upload?path=${encodeURIComponent(path)}`, undefined, data);
  }

  read(path: string): Promise<Uint8Array> {
    return call("GET", `${this.toolbox}/files/download?path=${encodeURIComponent(path)}`);
  }

  async fork(count: number): Promise<Sandbox[]> {
    const { sandboxes } = await call("POST", `${API}/sandboxes/${this.id}/fork`, { count });
    return sandboxes.map((s: any) => new Sandbox(s.id));
  }

  delete() { return call("DELETE", `${API}/sandboxes/${this.id}`); }
}

const sbx = await Sandbox.create();
let kids: Sandbox[] = [];
try {
  console.log((await (await sbx.wait()).exec("node --version")).stdout.trim());
  await sbx.write("/home/prized/work/hello.txt", "hello from TypeScript\n");
  console.log(new TextDecoder().decode(await sbx.read("/home/prized/work/hello.txt")).trim());
  kids = await sbx.fork(2); // two live copies, hello.txt included
  for (const kid of kids) console.log(kid.id, (await (await kid.wait()).exec("cat hello.txt")).stdout.trim());
} finally {
  for (const s of [sbx, ...kids]) {
    try { await s.delete(); console.log("deleted", s.id); }
    catch (e: any) { console.log("could not delete", s.id, e.message); } // try the rest; maxRuntimeMin removes this one anyway
  }
}
```

## 3. Run it [#3-run-it]

The demo creates a 2 vCPU, 4 GB sandbox that deletes itself after 60 minutes, runs a command, writes and reads a file, forks two copies that already have the file, and deletes all three. Python needs 3.8 or later:

```bash
python3 sandbox.py
```

Node 22.18 or later runs the TypeScript file as it is; on Node 18 to 22.17, run `npx tsx sandbox.mts` instead. Older Node has no global `fetch`, so upgrade it first.

```bash
node sandbox.mts
```

A run of the Python file takes about 8 seconds:

```text
Python 3.12.3
hello from Python
sbx_2f7f62ea3f77116f hello from Python
sbx_0426aba73aa4e2ff hello from Python
deleted sbx_723373199c11b589
deleted sbx_2f7f62ea3f77116f
deleted sbx_0426aba73aa4e2ff
```

The TypeScript file prints the same with `v22.23.3` and `hello from TypeScript`.

## What the helper handles [#what-the-helper-handles]

* **Waiting.** A create answers while the sandbox is `starting`. `wait()` reads it until `running`, keeps its `toolboxBase` (where its commands and files go), then runs `true` until the sandbox takes commands: for a moment after `running`, it can still answer `409` or `502`.
* **Retries.** `exec()` retries `409 sandbox_not_running` and `429 rate_limited`, which come before a command runs, up to 5 times. It never retries `502 sandbox_unreachable`, since the command may have run; for one that is safe to run twice, pass `retry=(409, 429, 502)` in Python or `[409, 429, 502]` as the third argument in TypeScript.
* **Cleanup.** `finally` deletes the sandbox and each fork even when a step or another delete fails, and `maxRuntimeMin: 60` deletes anything left behind.
* **Files.** `read()` returns bytes, so archives and images arrive intact; decode text yourself, as the demo does.
* **Long commands.** A command runs for up to 300 seconds unless you pass `timeout` (3,600 at most). Node's `fetch` stops waiting for an answer after 300 seconds, so in TypeScript run a job that may take that long in the background, such as `nohup ./job.sh > job.log 2>&1 &`, and read `job.log` with later commands.

## Errors [#errors]

An error carries the API's status and body: the `error` code, and fields such as `reason` or `fits`. When a [free-tier](https://prized.dev/docs/sandboxes#free-tier) limit refused the request, the body has `"tier": "free"` and the helper adds a link to the limits. The ones you are most likely to see ([every code](https://prized.dev/docs/sandboxes-api#errors)):

| Error                        | Means                                                                     | Do                                                                                        |
| ---------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| 429 `sandbox_quota_exceeded` | A quota or free-tier limit, named in `reason`. Paused sandboxes count     | Delete sandboxes you are done with; on the free tier, a top-up or a plan lifts the limits |
| 422 `invalid_resources`      | A shape outside the template's bounds, or the free tier's 2 vCPU and 4 GB | Ask for less                                                                              |
| 409 `fork_capacity`          | The parent's machine has room for only `fits` more copies                 | Fork `fits` or fewer; at `fits: 0`, fork a new sandbox instead, or wait                   |
