Sign in

Run an orchestrator agent with a sandbox per task

Keep the orchestrator agent on a box and give each builder or task its own sandbox, deleted when the task ends: a Python fan-out script, cleanup, costs, and limits.

On this page8

One agent plans and many build. Keep the orchestrator on a box, and give each builder a sandbox of its own:

  • The box holds the orchestrator agent, your repo, and its tools for days, and keeps working with your laptop closed (Boxes).
  • A sandbox per task is a clean microVM that takes commands seconds after the create, runs one task, and is deleted when it ends. Tasks run side by side and share nothing (Sandboxes).

The steps run a script that fans three test runs out to three sandboxes, collects each result, and deletes every sandbox, even when a task fails.

1. Put a sandbox key on the box

Make a key at https://prized.dev/dashboard/workspace/api-keys with Custom access, Sandboxes at Write and the rest at None, so the orchestrator can create and delete sandboxes but cannot touch your boxes. On the box (prized ssh), export it in the shell the orchestrator starts from:

export PRIZED_API_KEY=dcp_…

2. Save the script

Boxes have Python 3 with venv (Machines); the script needs requests too:

python3 -m venv ~/.fanout && ~/.fanout/bin/pip install -q requests
fanout.py
import contextlib, os, signal, sys, time, uuid
from concurrent.futures import ThreadPoolExecutor

import requests

API = "https://api.prized.dev/api/v1"
VCPU = int(os.environ.get("VCPU", "1"))  # each task's sandbox, with 2 GB of memory per vCPU
PARALLEL = int(os.environ.get("PARALLEL", "8"))  # sandboxes at once
TIMEOUT = 600  # seconds per task, under the sandboxes' 30 minute run time below
RUN = uuid.uuid4().hex[:8]  # labels this run's sandboxes, for cleanup
TASKS = {
    "itsdangerous": "git clone -q --depth 1 https://github.com/pallets/itsdangerous repo && cd repo && python3 -m venv .venv && .venv/bin/pip install -q . pytest freezegun && .venv/bin/pytest -q",
    "markupsafe": "git clone -q --depth 1 https://github.com/pallets/markupsafe repo && cd repo && python3 -m venv .venv && .venv/bin/pip install -q . pytest && .venv/bin/pytest -q",
    "more-itertools": "git clone -q --depth 1 https://github.com/more-itertools/more-itertools repo && cd repo && python3 -m unittest discover -s tests",
}

http = requests.Session()
http.headers["Authorization"] = f"Bearer {os.environ['PRIZED_API_KEY']}"
http.mount("https://", requests.adapters.HTTPAdapter(pool_maxsize=PARALLEL))


def call(method, url, ok=(200, 201, 202), **kw):
    r = http.request(method, url, timeout=kw.pop("timeout", 60), **kw)
    if r.status_code not in ok:
        raise RuntimeError(f"{method} {url.split('?')[0]}: {r.status_code} {r.text[:300]}")
    return r.json()


def create(name):
    for _ in range(20):  # no room yet: 503 sandbox_capacity, or 429 while other sandboxes hold the quota
        r = http.post(f"{API}/sandboxes?wait=running", headers={"Idempotency-Key": f"{RUN}-{name}"}, timeout=60, json={
            "template": "prized-base-v1",
            "resources": {"vcpu": VCPU, "memoryMb": 2048 * VCPU},
            "labels": {"fanout": RUN, "task": name},
            "maxRuntimeMin": 30,  # deleted after 30 minutes of running, even if this script dies
        })
        if r.status_code not in (429, 503):
            break
        time.sleep(float(r.headers.get("Retry-After") or 5))
    if r.status_code not in (200, 201):
        raise RuntimeError(f"create: {r.status_code} {r.text[:300]}")
    return r.json()["sandbox"]


def ready(sbx):
    for _ in range(120):  # a create can outlast the API's 8 second wait
        if sbx["state"] != "starting":
            break
        time.sleep(1)
        sbx = call("GET", f"{API}/sandboxes/{sbx['id']}")["sandbox"]
    if sbx["state"] != "running":
        raise RuntimeError(f"{sbx['id']} is {sbx['state']}: {sbx['errorReason']}")
    for _ in range(10):  # for a moment after running, the edge can answer 409, 502 or 503
        r = http.post(f"{sbx['toolboxBase']}/process/execute", json={"command": "true"}, timeout=30)
        if r.ok:
            return sbx
        time.sleep(1)
    raise RuntimeError(f"{sbx['id']} is not taking commands: {r.status_code}")


def delete(sbx_id):
    with contextlib.suppress(requests.RequestException):
        http.delete(f"{API}/sandboxes/{sbx_id}", timeout=60)


def run(name):
    t0, sbx = time.monotonic(), None
    try:
        sbx = create(name)
        sbx = ready(sbx)
        t1 = time.monotonic()
        out = call("POST", f"{sbx['toolboxBase']}/process/execute", json={"command": TASKS[name], "timeout": TIMEOUT}, timeout=TIMEOUT + 60)
        last = (out["stdout"].strip() or out["stderr"].strip()).splitlines()[-1:] or [""]
        return out["exitCode"] == 0, f"{name}: exit {out['exitCode']}, ready {t1 - t0:.1f}s, task {time.monotonic() - t1:.1f}s, {last[0]}"
    except Exception as e:
        return False, f"{name}: failed, {e}"
    finally:  # toss it as soon as its task is done
        if sbx:
            delete(sbx["id"])


def cleanup():  # anything else labeled with this run, except a sandbox you paused to keep
    for sbx in call("GET", f"{API}/sandboxes")["sandboxes"]:
        if (sbx.get("labels") or {}).get("fanout") == RUN and sbx["state"] != "paused":
            delete(sbx["id"])


def stop(signum, _):  # Ctrl-C or kill: clean up, then exit without waiting on the tasks' commands
    try:
        cleanup()
    finally:
        os._exit(128 + signum)


signal.signal(signal.SIGINT, stop)
signal.signal(signal.SIGTERM, stop)
t = time.monotonic()
with ThreadPoolExecutor(min(PARALLEL, len(TASKS))) as pool:
    results = list(pool.map(run, TASKS))
print(*(line for _, line in results), f"all {len(TASKS)} in {time.monotonic() - t:.1f}s", sep="\n")
cleanup()
sys.exit(0 if all(ok for ok, _ in results) else 1)

For each task in TASKS, a name and one shell command, it:

  • Creates a sandbox from prized-base-v1 (Python 3, Node.js 22, git), labeled with the run, with a 30 minute run time as a backstop. A create refused for room (503) or quota (429) waits its Retry-After and tries again; the Idempotency-Key keeps a retry from making a second sandbox.
  • Waits until it takes commands. A create can still be starting when the API's wait ends, and for a moment after running a command can answer 409, 502, or 503, so it retries a harmless true first (The edge).
  • Runs the task as the user prized, without sudo, with a TIMEOUT of 600 seconds, and reads exitCode: a failed command still answers 200.
  • Deletes the sandbox as soon as the task ends.

3. Run it

~/.fanout/bin/python fanout.py
# itsdangerous: exit 0, ready 4.2s, task 6.1s, 297 passed in 0.53s
# markupsafe: exit 0, ready 4.1s, task 6.2s, 39 passed, 41 skipped in 0.11s
# more-itertools: exit 0, ready 4.3s, task 10.8s, OK
# all 3 in 15.1s

ready runs from the create until the sandbox takes commands, about 4 seconds at 1 vCPU; a burst of creates can wait longer while capacity is added. Each line ends with the task's last line of output, and the script exits 1 if any task failed. VCPU=2 gives each sandbox 2 vCPU and 4 GB, a shape that usually starts in under a second (Quickstart), and PARALLEL caps how many run at once (8 unless set).

4. Give it your builders' tasks

  • Your work in TASKS: a test shard, a build, a branch to check out and test.
  • A builder that is an agent runs on prized-agent-v1, which has Claude Code and Codex. Nothing is signed in there, so add its key to the create in envVars, and make the task the agent's headless run (Create).
  • Private code goes in as an upload (git archive, then POST {toolboxBase}/files/upload), or as a clone with a token you pass in envVars.
  • Files a task writes go with its sandbox: download them before the delete with GET {toolboxBase}/files/download?path=… (Files).
  • The orchestrator agent can run the script, or make the same calls itself: the Prized skill teaches Claude Code and Codex the API. An agent's shell tool can stop a long command before its cleanup runs, so have it start the script in the background (nohup ~/.fanout/bin/python fanout.py > fanout.log 2>&1 &) and read fanout.log.

Clean up

The script deletes each sandbox when its task ends, then anything else labeled with its run that is not paused. Ctrl-C or kill runs the same cleanup and exits at once.

  • maxRuntimeMin: 30 deletes each sandbox after 30 minutes of running, even if the box running the script goes down, or a create still in flight when you stopped the script lands after the cleanup. Keep it above TIMEOUT.
  • Leftovers carry the label fanout. prized sandbox ls shows each sandbox's labels. With no run going, this deletes every sandbox with that label, paused ones included:
API=https://api.prized.dev/api/v1
AUTH="Authorization: Bearer $PRIZED_API_KEY"
for ID in $(curl -s "$API/sandboxes" -H @<(echo "$AUTH") | jq -r '.sandboxes[] | select(.labels.fanout) | .id'); do curl -s -o /dev/null -X DELETE "$API/sandboxes/$ID" -H @<(echo "$AUTH"); done

Optional: pause one, or fork a set-up one

  • Keep a failed sandbox to look into. In run(), pause it with call("POST", f"{API}/sandboxes/{sbx['id']}/pause?wait=paused") instead of deleting it; cleanup() leaves paused sandboxes alone. It keeps its memory, processes, and disk for the price of storage, and the next command resumes it, usually in under a second (Pause and resume). Delete it when you are done: a paid workspace keeps a paused sandbox until you do.
  • The same slow setup for every task? Set it up in one sandbox, then fork it with POST /api/v1/sandboxes/{id}/fork and {"count": 8}: each copy starts running with the setup done, and keeps the parent's labels, so the cleanup above finds it too (Fork a running VM).

What it costs

A sandbox bills per second while it runs, with no minimum: $0.04 per vCPU-hour, $0.0125 per GB-hour of memory, and $0.0001 per GB-hour of disk. A create still starting bills no compute, and one that never comes up costs nothing (Billing: Sandboxes).

The example's 1 vCPU, 2 GB sandboxes are $0.066 an hour each, so its three tasks, about 25 seconds of running in all, cost under a tenth of a cent, and a thousand 10 second tasks cost $0.18. The box bills like any box (Billing); a small one is enough, since the builds run in the sandboxes.

Limits

  • 50 at once. Once the workspace has paid in, 50 sandboxes can be up at once, with 200 vCPU, 400 GB of memory, and 500 GB of disk across them; email for more (Limits). A sandbox still deleting counts until it is gone, so keep PARALLEL a few under. On the free credit alone, 10 at once, each up to 2 vCPU, 4 GB, and 60 minutes (Free tier).
  • 48 commands at once per key. A command holds its request open until it ends, and the edge takes 48 at once per API key and 8 per sandbox; past that it answers 429 rate_limited (Edge limits). For more builders at once, or a task past the 1 hour timeout maximum, start the task in the background (nohup ./task.sh > task.log 2>&1 &), read task.log with short commands, and raise maxRuntimeMin above it.
  • Paused sandboxes still count. Their disk counts toward the 500 GB, and while one keeps CPU and memory reserved for its resume, they count against the running totals; at 500 paused, a create or fork answers 429 sandbox_quota_exceeded with reason paused_limit. Delete what you will not resume.