Prized Python SDK: Sandboxes (beta)
A sandbox is a microVM your code creates, runs commands in, moves
files in and out of, pauses for the price of its storage, forks into live
copies, and deletes. This package is the Python client for the Prized
Sandboxes API, with a sync Sandbox and an asyncio AsyncSandbox that share
one surface.
Sandboxes are in beta. A workspace on free credit alone runs them on the free tier: up to 10 at once, each up to 2 vCPU and 4 GB of memory and 60 minutes of running. A top-up or a paid plan lifts the limits: a sandbox then runs up to 7 days at a stretch, and a paused one is kept until you delete it.
Install
pip install prized
Python 3.10 or newer. The only dependency is httpx.
Authenticate
Make an API key at prized.dev/dashboard/workspace/api-keys and put it in the environment. New to Prized? Open Sandboxes signs you up (free credit, no card) and makes one.
export PRIZED_API_KEY=dcp_…
Or pass api_key="dcp_…" to any call. The key acts as you and sees every
sandbox in its workspace.
Quickstart
from prized import Sandbox
with Sandbox.create() as sb: # returns once it runs
result = sb.exec("python3 --version")
print(result.stdout, result.exit_code)
sb.files.write("hello.py", "print('hello from the sandbox')\n")
print(sb.exec("python3 hello.py").stdout)
# leaving the block deletes the sandbox
The same with asyncio:
import asyncio
from prized import AsyncSandbox
async def main() -> None:
async with await AsyncSandbox.create() as sb:
result = await sb.exec("node --version")
print(result.stdout)
asyncio.run(main())
Many at once, a few at a time: a workspace caps how many sandboxes it holds (10 on the free tier), and a sandbox being deleted still counts for a moment, so each one is gone before its slot takes the next command:
async def run_all(commands: list[str], limit: int = 4) -> list[str]:
slots = asyncio.Semaphore(limit) # 4 fits the free tier
async def one(cmd: str) -> str:
async with slots:
async with await AsyncSandbox.create(vcpu=2, memory_gb=4, labels={"job": "batch"}) as sb:
try:
result = await sb.exec(cmd, timeout=600) # past it: CommandTimeoutError
finally:
await sb.delete(wait=True)
if result.exit_code != 0:
raise RuntimeError(f"{cmd!r} failed: {result.stderr}")
return result.stdout
# Every job settles (and deletes its sandbox) before a failure is raised.
results = await asyncio.gather(*(one(c) for c in commands), return_exceptions=True)
for r in results:
if isinstance(r, BaseException):
raise r
return results
Reference
Everything below exists on both classes; on AsyncSandbox every method and
classmethod is awaited.
Sandbox.create(template="prized-base-v1", **options) -> Sandbox
Creates a sandbox and returns once it is running, ready for commands.
| Argument | Meaning |
|---|---|
template |
prized-base-v1 (Ubuntu with Node, Python 3.12 and git; Python has only its standard library, and packages go in a venv: python3 -m venv ~/.venv && ~/.venv/bin/pip install numpy) or prized-agent-v1 (adds Claude Code and Codex). Sandbox.templates() lists them with their shapes. |
vcpu, memory_gb or memory_mb, disk_gb |
The shape. Each defaults to the template's; the template sets the bounds (up to 32 vCPU, 128 GB of memory and 128 GB of disk; 2 vCPU and 4 GB on the free tier). |
env_vars |
dict[str, str] set for every command in the sandbox. |
labels |
dict[str, str] to find it by later (Sandbox.list(labels=...)). |
auto_pause_min |
Idle minutes before it pauses itself. Commands and file transfers count as activity. 0 turns it off. |
auto_delete_min |
Minutes after pausing before it is deleted. 0 turns it off. |
max_runtime_min |
Its run time (below): minutes of running before it is deleted. 24 hours by default, up to 7 days (10 080); 60 on the free tier. |
timeout |
Seconds for the request, its retries and the wait for running (default 180). A large shape (more than 16 vCPU or about 58 GB of memory) can take a minute or two to start: give it timeout=300 or more. |
wait |
False returns as soon as the API accepts (state starting), still within timeout. |
idempotency_key |
Makes a retried create return the same sandbox. The SDK sends a fresh one per call when you don't, so its own retries never make a second sandbox. |
If the sandbox fails to start or timeout runs out, the SDK raises
PrizedError (code sandbox_error or timeout; sandbox_id set) and
deletes the sandbox. A create that is interrupted (Ctrl-C, a cancelled task)
or never gets an answer (timeout or network_error once its retries are
spent) finds the sandbox it may have made and deletes it too.
With your own idempotency_key, the SDK deletes nothing: that key may be
shared with another call holding the same sandbox. The error names the
sandbox (sandbox_id); resend the create with the key to get it back, or
delete it. fork() follows the same rules for its copies.
That cleanup is best effort, bounded (10 seconds for a create, a little more
per copy for a fork): if Prized cannot be reached meanwhile, a sandbox may be
left until its run time ends, or, if it pauses first (auto_pause_min), until
you delete it (free tier: 30 days after its last use). Label your sandboxes, and
Sandbox.delete_by_labels(...) deletes any leftovers once it can.
Create, pause() and resume() ask the API to answer once the change has
landed, and only poll if it has not within a few seconds.
Every classmethod also takes the connection options:
| Option | Default |
|---|---|
api_key |
PRIZED_API_KEY |
base_url |
https://api.prized.dev |
edge_url |
The command endpoint the API names for each sandbox |
http_client |
A client the SDK owns. Pass your own httpx.Client / httpx.AsyncClient for proxies or custom transports; the SDK never closes it. |
max_retries |
3 |
poll_outage |
120 seconds: how long a streamed or long exec keeps polling through a dropped connection before it stops the command |
Find sandboxes
sb = Sandbox.connect("sbx_…") # any state; no waiting
mine = Sandbox.list(labels={"job": "batch"}, state="running")
templates = Sandbox.templates() # [Template(id, defaults, min, max, ...)]
list() returns the workspace's sandboxes that are not yet deleted, newest
first. labels keeps those carrying every label; state takes one state or
several.
Delete by label
ids = Sandbox.delete_by_labels({"job": "batch"}) # every sandbox carrying it, in one call
delete_by_labels() needs at least one label and returns the ids it deleted.
It is safe to repeat (one already deleting is listed again; none matching
returns []). A sandbox being deleted still counts toward your quota until
Sandbox.list(labels=...) no longer returns it.
Fields
sb.id (sbx_…), sb.state, sb.template, sb.resources (vcpu,
memory_mb, memory_gb, disk_gb), sb.labels, sb.auto_pause_min,
sb.auto_delete_min, sb.expires_at (when its run time ends; None while
a paused sandbox's clock is stopped), sb.created_at, sb.last_active_at,
sb.error_reason, and sb.info (all of it; sb.info.raw is the document
as the API sent it).
States: starting, running, pausing, paused, resizing, deleting,
deleted, error. Fields reflect the last answer the SDK read;
sb.refresh() reads the sandbox again.
sb.exec(cmd, cwd=None, env=None, timeout=None, on_stdout=None, on_stderr=None, background=False, full_output=False, poll_outage=None)
Runs one command with bash as the user prized and returns
ExecResult(stdout, stderr, exit_code, truncated, timed_out, duration_ms).
cmdis a shell string ("ls -la | wc -l") or a list of arguments (["python3", "-c", code]), which the SDK quotes for you.cwddefaults to/home/prized/work; relative paths resolve against it.envadds variables for this command, on top of the sandbox'senv_vars.timeoutis in seconds: default 60,0for no limit. A command that runs past it is killed, and the call raisesprized.CommandTimeoutError(aPrizedError,codecommand_timeout). Itsresultis theExecResultwith what the command printed before (alsoerr.stdout,err.stderr),exit_code=-1andtimed_out=True.- A non-zero exit code is a result, not an exception.
- One call keeps the first 4 MiB of stdout and of stderr, and
truncated=Truesays when it cut one. Passfull_output=Truefor builds, test suites and anything else whose output you need whole: the result keeps up to 100 MiB of each stream (truncated=Truepast that). It is still one request when the output is small; past 4 MiB the SDK reads it back from files in/home/prized/.prized/exec/, then removes them. With it, a command a signal ends getsexit_code128+n (a plain exec may give -1), as a background command does. on_stdout/on_stderrreceive the output as it arrives. OnAsyncSandboxthey may be coroutine functions.- A streamed command (or one with a
timeoutover 240 seconds, or0) rides out a dropped connection: the SDK keeps polling it for up topoll_outageseconds (default 120, set on the call or the client) before it gives up and stops the command; a refused key, or an answer that settles it (the sandbox deleted or not running), stops it at once.
result = sb.exec("npm test", timeout=180, full_output=True) # every line of the log
sb.exec(
"python3 -m venv .venv && .venv/bin/pip install -r requirements.txt",
timeout=600,
on_stdout=lambda out: print(out, end=""),
) # chunks keep their own newlines
Background commands
background=True starts the command and returns a CommandHandle at once.
The command runs in its own session: it keeps running after your process
exits, and while it runs, kill() reaches every process it started (not one
that left its process group, such as a daemon). Once the command has ended,
kill() returns False and signals nothing.
server = sb.exec("python3 -m http.server 8000", background=True)
server.status() # CommandStatus(running=True, exit_code=None)
server.logs() # CommandLogs(stdout=..., stderr=...) so far
server.kill() # SIGTERM (or kill("SIGKILL")); False if it had exited
result = server.wait(on_stdout=lambda out: print(out, end="")) # ExecResult once it ends
server.cleanup() # remove its log files from the sandbox
wait(timeout=...) bounds the wait only; the command keeps running, and
wait() returns what the command printed by the time it ended (not what a
process it left behind prints after). Pass on_stdout/on_stderr to
wait(); exec(..., background=True) refuses them. A background command has
no time limit unless you pass timeout= (whole seconds); one that runs past
its limit is killed, wait() raises CommandTimeoutError at once, and
anything it started that ignores the TERM is killed 5 seconds later. env applies to the command
itself. Background and streamed commands run with PYTHONUNBUFFERED=1 unless their environment
already sets it (env={"PYTHONUNBUFFERED": ""} keeps Python's buffering), so a Python program's
output arrives as it prints. Its output is kept in /home/prized/.prized/runs/ until cleanup(),
which removes it once the command has ended (a command still running keeps
its files, and its time limit); a command that prints without end fills the
sandbox's disk there, so redirect such output yourself (> /dev/null, or a
log you rotate). logs() says truncated=True when a stream passed what the
SDK keeps.
sb.files
| Method | Does |
|---|---|
write(path, data, mode=None) |
Writes str (UTF-8) or bytes, creating parent directories and replacing any file there whole. A file it replaces keeps its mode, so a script made executable stays executable; a new file gets the sandbox's default (0600 in prized-base-v1 and prized-agent-v1). mode=0o755 sets the permission bits. Returns WriteInfo(path, size). |
read(path) / read_bytes(path) |
The contents as str / bytes. |
upload(local_path, path, mode=None) |
Copies a local file in, as write does. Returns WriteInfo. |
download(path, local_path) |
Copies a file out (local parent directories are created). Returns the byte count. |
list(path="/home/prized/work") |
The entries of a directory, sorted by name: FileInfo(name, path, size, is_dir, is_symlink, mode, modified). |
remove(path, recursive=False) |
Deletes a file or an empty directory; recursive=True deletes a directory and everything under it. A missing path is not an error. |
exists(path) |
True or False. |
Paths are absolute or relative to /home/prized/work. write, read,
upload and download work inside /home/prized.
Lifecycle
sb.pause() # memory and disk kept (until you delete it; free tier: 30 days); no compute charged
sb.resume() # every process carries on where it left off
kids = sb.fork(4) # 4 new sandboxes, each a live copy of this one (running or paused)
sb.refresh() # read the sandbox again (state and the other fields)
sb.keep_alive() # count as activity now: the auto-pause clock starts over
sb.delete()
sb.close() # release the SDK's connections; the sandbox keeps running
pause()andresume()return once the sandbox ispaused/running. Passwait=Falseto return as soon as the API accepts, andtimeout=(seconds) to bound the whole call (the request, its retries and the wait).- A command or file call on a paused sandbox resumes it first (billed and
counted as activity like any resume), so
resume()is optional before one. fork(count=1, labels=None)works on arunningor apausedsandbox. Each copy is an ordinary sandbox with its own id, its own files from then on, and the parent's memory and every process as they were at the fork (or at the pause), running. A running parent pauses briefly, usually under a second, and keeps running; a paused one stays paused. All or nothing: it returns once every copy is running; if any copy fails, or the call runs out of time (timeout=, default 180 s, the request and the wait together), every copy is deleted andPrizedErroris raised. A fork that does not fit raisesfork_capacity, whose message says how many copies fit now (err.body["fits"]). The copies keep the parent's labels unless you passlabels=, which gives them only the labels you pass.delete()returns once the API accepts;delete(wait=True)waits until the sandbox is gone (a deleting sandbox still counts toward quotas). Deleting a sandbox that is already gone is not an error.with Sandbox.create(...) as sb:deletes the sandbox when the block ends, error or not.
Run time
A sandbox is deleted when its run time ends (sb.expires_at). The clock
counts running time only:
- Paid: 24 hours by default, up to 7 days (
max_runtime_minon create). The clock stops while the sandbox is paused, and each resume starts it again with a freshmax_runtime_min. A paused sandbox is kept, memory and disk, until you delete it (or itsauto_delete_mindoes). - Free tier: 60 minutes of running in all. Paused time does not count, but a resume carries on the time left. A paused one is kept 30 days after its last use.
- Forks: on a paid workspace each copy gets the parent's
max_runtime_minfrom the fork; on the free tier copies keep the parent's deletion time. - Extend a running one with the CLI (
prized sandbox extend <id> --by 2h) or the API (POST /api/v1/sandboxes/{id}/extend; see Run time). On the free tier an extend cannot move the end later.
Errors
Every failure of a call to Prized raises prized.PrizedError. Two kinds keep
Python's own exceptions: arguments the SDK refuses before sending anything
raise ValueError or TypeError, and files.upload() / files.download()
raise OSError for the local file.
| Attribute | |
|---|---|
code |
Machine-readable: branch on this. |
status |
The HTTP status, or None (network failure, timeout, missing key). |
message |
A sentence for people. |
reason |
A finer reason when the API gives one, e.g. which quota. |
sandbox_id |
The sandbox concerned, when there is one. |
body |
The API's whole error document. |
from prized import PrizedError, Sandbox
try:
sb = Sandbox.create()
except PrizedError as err:
if err.code in ("paid_balance_required", "insufficient_balance"):
print("Add funds at prized.dev, then retry.")
elif err.code == "sandbox_quota_exceeded":
print(f"At a workspace quota ({err.reason}).")
else:
raise
Codes you may meet:
| Code | Means |
|---|---|
missing_api_key |
No api_key= and no PRIZED_API_KEY. |
invalid_api_key |
The key has characters no key has (a stray newline?); it is never quoted back. |
edge_unknown |
A base_url other than Prized's, whose API named no command endpoint on its own host: pass edge_url=. |
unauthorized, token_expired |
The key was refused. |
key_access_denied |
The key lacks the Sandboxes permission. |
paid_balance_required |
The workspace has only free credit, and sandboxes need a paid balance right now; add funds. |
insufficient_balance |
The balance is empty. |
sandbox_quota_exceeded |
A workspace quota is full; reason names it. On the free tier the body says "tier": "free" and gives limit in the reason's unit (err.body["limit"]): a top-up lifts it. |
template_not_found, invalid_resources |
Check Sandbox.templates(). invalid_resources with "tier": "free" is past the free tier's 2 vCPU / 4 GB. |
not_found |
No such sandbox (or file) for this key. For a sandbox, most often one deleted when its run time ended; the message names it. |
sandbox_not_running |
The sandbox is not running and the call could not wake it: it is resizing (refresh() until it leaves resizing, then retry), failed, or being deleted. |
sandbox_not_ready |
A paused or starting sandbox did not reach running within the edge's 10-second wait. Nothing ran; the SDK retries it. |
sandbox_unreachable |
The sandbox did not answer. A command may or may not have run; the SDK does not retry it. |
sandbox_deleted |
The sandbox was deleted: a wait found it gone, a command or file call was sent to it before or while it was deleted (410; a command that was running stopped with it), or pause() or resume() was called while it was being deleted (410). Not retried. |
parent_not_running |
Only a running or paused sandbox can be forked; fork one that is starting, pausing or resizing once it settles. |
parent_not_forkable |
This sandbox cannot be forked. reason resized_while_paused: it has no memory to copy; resume it, then fork it. reason parent_too_big: its machine cannot hold even one copy beside it; fork a smaller sandbox. |
fork_capacity |
The parent's machine has room for only fits more copies (err.body["fits"]): fork that many or fewer, try again later, or create a new sandbox and fork that one, keeping the parent until it runs. Each copy counts toward your quota. |
sandbox_capacity |
No sandbox capacity is free right now. Nothing was created or billed; the SDK retries it after Retry-After. |
sandboxes_disabled, sandbox_create_disabled, sandbox_fork_disabled |
Sandboxes, or creating or forking them, are paused for maintenance. Nothing was created; retry later (the SDK does not). |
sandbox_error |
The sandbox failed; reason says why. |
command_failed |
A file or background-command helper failed in the sandbox. |
command_timeout |
A command ran past its own timeout= and was killed. Raised as CommandTimeoutError, whose result has what it printed. |
timeout |
A wait, or a call, ran out of time. One that ended because a retry could not start in time says what the last attempt met (also in __cause__). |
network_error |
No answer: the API or the sandbox could not be reached, or the connection dropped. |
http_error |
An answer the SDK could not read as an API error (a proxy's page). |
bad_response |
A 2xx the SDK could not read. For a command or file write it did happen, and the message says so. |
Retries. The SDK retries rate limits (429, honouring Retry-After),
502, 503 and 504 answers, dropped connections, and every answer a
command is retried on, with backoff (0.5 s, 1 s, 2 s, …), up to
max_retries times. A read (Sandbox.list, connect, refresh,
templates, a file download, and the polling while create, pause,
resume, fork or delete waits) is retried on any other 500 too; a
write is not, since it may already have happened. A command, a
files.write / files.upload, a files.remove or a kill() is sent again only when
it provably never arrived: the connection never opened, or the answer says
so (rate limited, the sandbox not ready). One whose answer was lost is not
sent again: the command may have run, or the sandbox written the file
since. Quota, balance and idempotency refusals, and the maintenance switches above, are never retried.
Did it run? When a command, a file write, a files.remove or a kill()
fails without a plain refusal, the error keeps its code and its message
says which of three it was. It never reached the sandbox (the connection
never opened, or the answer proves it, such as sandbox_not_ready): it had
no effect. No clear answer came back (a dropped connection, a timeout, or
any other 5xx): it may or may not have run, and the message says how to
check (read the file back, files.exists(), status()). A 2xx came back
that could not be read: it ran. A background command (or a streamed or long
exec, which runs as one) whose start got no clear answer is stopped by the
SDK, and the error says so, or, when the SDK could not confirm the stop, that
it may still be running and under which run id. So is a streamed or long
exec whose polling fails while it runs (a dropped connection or a 5xx
for longer than poll_outage, a refused key): the error keeps the poll's
code and status, is raised from the poll's own error (__cause__), and
says whether the SDK stopped the command or it may still be running. An
error that settles it (the sandbox deleted or not running, the run not
found) is raised as it came.
Deadlines. Retries stay within a call's bound: timeout= for create,
pause, resume, fork and delete (the request, its retries and the
wait, its polls included; with wait=False too), 2 minutes for connect,
list, templates, refresh and keep_alive, and 10 minutes for a file
transfer. Retry-After is honoured up to a minute. A retry that could not
start in time is not sent: the call raises timeout. A wait's last poll
goes out just before its deadline.
Limits
| Shape | Up to 32 vCPU, 128 GB memory and 128 GB disk per sandbox, within the template's bounds (Sandbox.templates()); 2 vCPU and 4 GB on the free tier. |
| Command output | One call keeps the first 4 MiB of stdout and of stderr (past that, truncated=True); with full_output=True it keeps up to 100 MiB of each. Streamed and background commands read all of it, and their result keeps the first 64 Mi characters of each stream (truncated=True past that; on_stdout/on_stderr still get every chunk). |
| Command time | 60 s by default; timeout=0 for none. |
| File transfer | 100 MiB per file, inside /home/prized. |
| Fork | 1 to 256 copies per call, subject to capacity. |
| Run time | Paid: 24 hours of running by default, up to 7 days; the clock stops while paused and restarts on resume. Free tier: 60 minutes of running in all. |
| Paused | Kept until you delete it; on the free tier, 30 days after its last use. |
| Workspace | Quotas cap sandboxes and their total vCPU, memory and disk. The free tier: 10 sandboxes at once. |
Pricing is per second while running, and storage only while paused: prized.dev/pricing.
Not yet: an interactive terminal from the SDK, custom templates, more than 32 vCPU or 128 GB, GPUs.
Development
cd sdk/python
python -m venv .venv && .venv/bin/pip install -e '.[dev]'
.venv/bin/ruff check . && .venv/bin/ruff format --check . && .venv/bin/mypy && .venv/bin/pytest
The tests run against an in-memory fake of the API (tests/fake_api.py) and
never touch the network. scripts/package_check.py builds the wheel and sdist
and checks them as PyPI gets them. scripts/live_smoke.py drives real
sandboxes end to end with PRIZED_API_KEY; it costs money and is not run in CI.
License
Apache-2.0. The full text is in the LICENSE file that ships with the package.
Metadata
Release files for prized 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| prized-0.1.0.tar.gz | 80.7 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| prized-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 168.0 kB
Release files / prized-0.1.0.tar.gz
| Download URL | prized-0.1.0.tar.gz |
|---|---|
| Size | 80.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
28532e65acdf83493ce4f3c28573f08d3fa3a8da899786e9957895a6a4d84c8c
|
|
BLAKE2b-256 checksum How to use checksums |
3723e69de3be406094b25dc415e8bf7a489f7daa265f7f561edbaa00fc15a141
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 26, 2026.
Transparency logRelease files / prized-0.1.0-py3-none-any.whl
| Download URL | prized-0.1.0-py3-none-any.whl |
|---|---|
| Size | 87.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
574405b338f82c3316943e8a6aa8eba523d92be0b6d50b1501de60dadb5de5c1
|
|
BLAKE2b-256 checksum How to use checksums |
0da4bfe0f67cf61fbf5481a332cbc605c7af2dd78b73b2caa586b17772775edd
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 26, 2026.
Transparency logSecurity for prized 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for prized-0.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 26, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.