instabox Python SDK
A small, typed Python client for instabox sandboxes: create a
sandbox, run commands in it, move files, pause and resume it, snapshot it and delete it.
Python 3.9-3.13, one dependency (httpx). It installs as instabox-sdk and imports as
instabox (the instabox project on PyPI is someone else's):
pip install instabox-sdk
It is on PyPI as instabox-sdk. A version bump in
pyproject.toml merged to main publishes a new release
(.github/workflows/sdk-python-release.yml; see Publishing).
Quickstart
Create an API key on API keys in the dashboard
(Full access, or a restricted key with machines:write and machines:delete, which is
enough for the quickstart; the snapshots calls also need images:write (create),
images:read (list and get) and images:delete (delete)), then:
export INSTABOX_API_KEY=ibk_...
from instabox import Client
client = Client() # reads INSTABOX_API_KEY (and INSTABOX_API_URL, default https://api.instabox.sh)
box = client.machines.create(name="my-box") # template agent-browser; waits until ready
try:
r = client.machines.exec(box.id, "uname -a")
print(r.stdout, end="")
r = client.machines.exec(box.id, ["sh", "-c", "echo oops >&2; exit 3"])
print(r.exit_code, r.stderr, end="") # 3 oops
finally:
client.machines.delete(box.id)
A sandbox pauses after 30 idle minutes by default. To pick one up later, whatever state it is
in, and keep working (this block runs on its own: it makes my-box first, as an earlier run
would have, and deletes it at the end):
from instabox import Client
client = Client()
client.machines.create(name="my-box") # stands in for a sandbox made earlier
try:
box = client.machines.ensure_running("my-box") # starts or resumes it, waits until ready
print(client.machines.exec(box.id, "ls /data/home").stdout, end="")
client.machines.update(box.id, metadata={"stage": "review"}) # labels: any plan
# Paid plans only; on the free plan another idle time is 403 spec_not_allowed:
# client.machines.update(box.id, auto_suspend_after_seconds=600)
finally:
client.machines.delete("my-box", confirm="my-box") # refuses to delete anything but my-box
Calls
client = Client(api_key=None, *, base_url=None, timeout=30.0, http_client=None)
| Call | Returns |
|---|---|
client.machines.create(name=None, *, template=None, snapshot_id=None, vcpus=None, mem_mib=None, persistent_disk_gib=None, metadata=None, org_id=None, auto_suspend_after_seconds=None, archive_seconds=None, delete_after_seconds=None, desired_state=None, fail_fast=False, wait=True, timeout=600) |
Machine, running and ready when wait (created, when desired_state="stopped") |
client.machines.list(*, metadata=None) |
every Machine of your orgs (all pages) |
client.machines.list_page(*, cursor=None, limit=None, metadata=None) |
MachinePage(machines, next_cursor) |
client.machines.get(machine) |
Machine (by ID or name) |
client.machines.update(machine, *, name=None, metadata=None, auto_suspend_after_seconds=None, max_run_minutes=None, vcpus=None, mem_mib=None, persistent_disk_gib=None, idempotency_key=None) |
Machine, updated (PATCH, synchronous) |
client.machines.exec(machine, command, *, cwd=None, env=None, stdin=None, timeout=60) |
ExecResult(stdout, stderr, exit_code, timed_out, truncated, signal) |
client.machines.start(machine, *, wait=True, timeout=600) |
Operation; with wait, the sandbox is running and ready |
client.machines.stop(machine, *, shutdown_timeout=None, wait=True, timeout=600) |
Operation |
client.machines.restore(machine, snapshot=None, *, stopped=False, wait=True, timeout=600) |
Operation; the sandbox's disks are back to the snapshot's (default: the one it was created from), same ID and URLs; with wait, running and ready unless stopped |
client.machines.delete(machine, *, confirm=None, wait=True, timeout=600) |
Operation |
client.machines.pause(machine, *, wait=True, timeout=600) |
Operation; the sandbox is suspended, memory and programs kept |
client.machines.resume(machine, *, wait=True, timeout=600) |
Operation; with wait, the sandbox is running and ready |
client.machines.wake(machine) |
Machine; an idle-paused sandbox is resumed first (up to 45 s) |
client.machines.ensure_running(machine, *, timeout=600) |
Machine, running and ready; a stopped or paused one is started first (one still stopping or pausing is let finish, then started; a start someone else began is watched, not raised) |
client.machines.wait_ready(machine, *, timeout=300) |
Machine once running and ready |
client.operations.get(operation_id) / client.operations.wait(operation_id, *, timeout=600) |
Operation |
client.templates.list() |
[Template] |
client.snapshots.list() / get(snapshot_id) |
[Snapshot] / Snapshot |
client.snapshots.create(machine, name, *, description=None, root_only=False) |
Snapshot; Snapshot.status is pending while a running sandbox's copy uploads, then ready (or failed, with error): poll get until it is ready |
client.snapshots.delete(snapshot_id, *, confirm=None) |
None |
client.files.write(machine, path, data, *, mode=None, parents=True, timeout=300) |
FileWritten(path, size, sha256, mode, created); data is str, bytes, a binary file object or an iterable of chunks |
client.files.read(machine, path, *, max_bytes=None) / read_bytes(machine, path, *, max_bytes=None) |
str (UTF-8) / bytes |
client.files.read_stream(machine, path, *, timeout=300) |
FileStream: iterate it for bytes chunks |
client.files.upload(machine, local_dir, remote_dir, *, git=True, all=False) |
UploadResult(path, files, bytes) |
client.files.download(machine, remote_dir, local_dir) |
DownloadResult(path, remote_path, files, bytes) |
client.files.list(machine, path) |
[FileEntry(name, type, size, mode, mod_time, target)] |
client.files.exists(machine, path) / remove(machine, path) |
bool / None (rm -rf) |
machineis a sandbox ID (m_...) or its name.createwithout a name pickspy-<random>. Withouttemplateorsnapshot_idit usesagent-browser, the default template;templatemay pin a version asid@version(agent-browser@2026-10-15.4).createoptions:auto_suspend_after_secondsis the idle pause (default 30 minutes; a paid sandbox takes 300-1800 in whole minutes or 0 for never, where the plan allows; the free sandbox is fixed at 1800, and another value is403 spec_not_allowed,PermissionDeniedError).archive_secondsstops anddelete_after_secondsdeletes the sandbox that many seconds (60-86400) after creation, so it cannot outlive a client that died.desired_state="stopped"creates it stopped (nothing to wait for).fail_fast=Truehas a create no host has room for refused at once (503 no_capacity,ServerError, nothing created) instead of returning an operation that then fails (OperationFailedError).updateisPATCH /v1/machines/{id}:name,metadata(replaces it;{}clears) andauto_suspend_after_secondsapply in any state;vcpus/mem_mib(another of the workspace's sizes) need the sandbox stopped and apply at its next start;persistent_disk_gibgrows a running sandbox's disk online (a stopped one's at its next start) and shrinks only while stopped:resizewraps it. Absent fields are unchanged.persistent_disk_gibis sent as the API'spersistentDiskGiB. On api.instabox.sh the disk (/data) comes with the size, so leave it out: another size is400 spec_not_allowed(BadRequestError). Resize a paid sandbox's disk later withclient.machines.resize(m, disk_gib, restart=False), which sendsPATCH /v1/machines/{id}{"persistentDiskGiB": N}(1-1024 GiB;update(m, persistent_disk_gib=N)sends that field as is): a running sandbox grows online; shrinking needs it stopped (restart=Truestops and restarts it) and the data plus 10% (at least 1 GiB) to fit, elseConflictErrorwith codedisk_too_small. The system disk is the template's:rootDiskGiBfrom anyone but an admin is400 spec_not_allowed.vcpusandmem_mib(the API'svcpusandmemMiB) name one of the org's sizes at create. A paid sandbox changes size later, while stopped, withclient.machines.update(m, vcpus=N, mem_mib=M), which sendsPATCH /v1/machines/{id}{"vcpus": N, "memMiB": M}(the CLI'sinstabox resize <sandbox> --vcpus N --mem-mib M; on a running or paused sandbox add--restart, from outside it), applied at its next start (which fails withno_capacityif the host has no room for it; the sandbox stays stopped at the new size).client.machines.resizetakes only the disk. Running or paused is409 operation_conflict(ConflictError; stop a running sandbox first, start then stop a paused one); another size, or the free sandbox,400 spec_not_allowed.exectakes a shell string, run withbash -lcas the sandbox's user (dev, HOME/data/home), or an argv list, run with no shell (the API'sargv: each element reaches the program exactly as sent, no$expansion, globbing or word splitting; a program that is not found exits 127). A non-zero exit code is a result, not an error. Whentimeout(seconds, at most 600) passes, the command is killed and the result hastimed_out=Trueandexit_code=124.execis not idempotent, so it is never retried. The sandbox must be running (409 machine_not_running,ConflictError):ensure_runningfirst when it may have been paused or stopped.wakeis for a sandbox that pauses itself when idle: it records activity on a running one or waits for the automatic pause and its resume to finish, so the nextexecfinds it running. A sandbox paused or stopped on purpose is not woken;start(orensure_running) does that.delete(confirm=...)sendsX-Confirm-Deletewith the sandbox's ID or exact name; any other value is409 delete_confirmation_required(ConflictError) and nothing is deleted. Withoutconfirm,deletesends themachineit was given (ID or name).snapshots.deletetakes the sameconfirm.create,start,stop,delete,updateand the snapshot calls send anIdempotency-Key(random per call). Passidempotency_key=to retry a call safely: the same key and request return the original response, so a retried create returns the same sandbox.filespaths are absolute, or relative to the sandbox's HOME (/data/home).writereplaces the file atomically as the sandbox's user andparentscreates missing directories; a missing file isNotFoundError, a directoryConflictError(not_a_file). Large files, directories and binary stdin has the rest.exec'sstdinisstror bytes and reaches the command byte for byte.Machinehasid,name,state(creating,starting,running,stopping,stopped,suspending,suspended,resuming,deleting,deleted,error),readyandrunning(running and ready),template_id,template_version,snapshot_id,spec,metadata,auto_suspend_after_seconds(None: never; sent as0, reported asNone),max_run_minutes,archive_at,delete_at,guest_user,processes_preserved(after a start or resume:Falsewhen the paused programs could not be brought back),free,org_id,operation_id,last_error,created_atandupdated_at.Snapshothasid,name,status(pending,ready,failed),source_machine_id,template_id,org_id,description,error(why it failed) andcreated_at.- Every model keeps the full JSON response in
.raw.
Large files, directories and binary stdin
With client and box from the quickstart (local paths are relative to the current directory,
sandbox paths to HOME in the sandbox):
import hashlib
# Stream a file of any size (up to 5 GiB) in, and back out, without holding it in memory.
with open("dataset.bin", "rb") as f:
client.files.write(box.id, "dataset.bin", f)
with client.files.read_stream(box.id, "dataset.bin") as src, open("copy.bin", "wb") as out:
for chunk in src:
out.write(chunk)
# Any iterable of bytes works too: each chunk is sent as it is made.
rows = (f"{i},{i * i}\n".encode() for i in range(100_000))
client.files.write(box.id, "squares.csv", rows)
# A whole directory, one call each way.
up = client.files.upload(box.id, "my-app", "my-app") # UploadResult(path='/data/home/my-app', ...)
down = client.files.download(box.id, "my-app/dist", "dist") # DownloadResult(path='dist', ...)
# Bytes on stdin arrive exactly as sent.
with open("logo.png", "rb") as f:
logo = f.read()
r = client.machines.exec(box.id, "sha256sum", stdin=logo)
assert r.stdout.split()[0] == hashlib.sha256(logo).hexdigest()
write, read, read_bytes and read_stream move the bytes in one request each over the API's
file routes (PUT /v1/machines/{id}/files and GET /v1/machines/{id}/files/content), the path
instabox cp uses.
writetakesstr(sent as UTF-8), bytes, a binary file object (open(p, "rb")) or any iterable ofbytes(orstr) chunks, such as a generator. A file object or an iterable is sent as it is read, chunked, so memory stays flat whatever its size: 100 MiB moved with a 3 MiB peak of Python allocations (tests/test_stream_memory.pyholds it under 16 MiB, and RSS growth under 64 MiB). The server takes up to 5 GiB per file (MAX_UPLOAD_BYTES;too_largepast it). A stream can be sent only once, so before the first one the SDK checks that the server has the route.timeoutis how long a transfer may make no progress.writechecks the size and SHA-256 the server reports against what was sent (transfer_mismatchwhen they differ). An exception from your data raisesAPIErrorcodesource_error(yours as__cause__), and the file keeps its old content.read_bytesandreadload the file into memory;max_bytesrefuses a larger one withfile_too_largebefore reading it.read_streamreturns aFileStreamas soon as the file is open (a missing file raisesNotFoundErrorthere, before any byte); iterate it forbyteschunks of up to 256 KiB, and use it in awithblock (or callclose()) if you stop early.sizeis the file's size. A stream that ends short of it raisesfile_changed.upload(machine, local_dir, remote_dir)anddownload(machine, remote_dir, local_dir)move a directory's contents as one gzipped tar, the wayinstabox pushandinstabox pulldo:uploadpacks it locally as it streams it to a temporary file in the sandbox (/tmp/.instabox-transfer-*), then unpacks it there withtar;downloadpacks the directory withtarin the sandbox (as root when sudo allows it, as the file routes read), streams the archive back and unpacks it as it arrives. Neither holds the archive in memory, and the temporary file is removed.- What
uploadsends: in a git work tree, only the files git tracks (git ls-files), asinstabox pushdoes;all=Trueadds untracked files git does not ignore, andgit=Falsesends everything under the directory. Outside a git work tree everything is sent, dot files and empty directories included.downloadbrings everything underremote_dir. - Symlinks are copied as symlinks (their target text as is), never followed. Sockets, FIFOs and devices are skipped. Permission bits are kept; owners are not. The destination and its parents are created; files the archive names are replaced, others kept (a merge, not a mirror).
downloadrefuses an archive entry that would land outsidelocal_dir(an absolute path,.., or a path through a symlink) withinvalid_response, and a file entry replaces a symlink at its path instead of writing through it. It checks each entry itself rather than usingtarfile'sfilter="data", which Python 3.9.0-3.9.16, 3.10.0-3.10.11 and 3.11.0-3.11.3 lack and which refuses symlinks that point outside the directory.exec(..., stdin=...): up to 1 MiB of UTF-8 goes in the exec request. Anything else (bytes that are not UTF-8, or more than 1 MiB, text too) is first written to/tmp/.instabox-stdin-<hex>(mode 0600), and the command is prefixedexec <FILE || exit 1; rm -f -- FILE, asinstabox exec --stdindoes: the command reads the exact bytes, and the file is gone once it starts.- On a server without the file routes,
writeof bytes,read,read_bytesandread_streamgo in base64 chunks over exec (writecapped at 10 MiB,file_too_large);upload,downloadand staged stdin move their file in base64 chunks of 768,000 bytes over exec, with no cap but slowly. - Errors are
APIErrors with these codes:local_not_foundandlocal_io_error(the local side),not_a_directory(a file where a directory was expected),transfer_failed(taror a script failed in the sandbox;PermissionDeniedError,permission_denied, when it was refused access),source_error,transfer_mismatch,file_changed,file_too_large,too_large,invalid_response,request_timeoutandunsupported(a stream on a server that lost its file routes mid-way). A missing sandbox directory isNotFoundError.
Errors
Every error is an instabox.InstaboxError. API errors are instabox.APIError subclasses
with the fields of the API's error body: code, message, request_id, retryable,
operation_id, plus status (HTTP) and retry_after (seconds to wait before retrying: the
Retry-After header, else the body's retryAfterSeconds, which is all a failed operation
has; set on rate_limited, retryable no_capacity and machine_cooldown).
| Status | Class | Example code |
|---|---|---|
| 400 | BadRequestError |
bad_request, spec_not_allowed (a size or disk the plan does not offer) |
| 401 | AuthenticationError |
missing_key, malformed_key, invalid_key, expired_key, revoked_key; missing_api_key when no key is set (raised by Client()) |
| 403 | PermissionDeniedError |
missing_scope, template_not_allowed, spec_not_allowed (another idle time on the free plan) |
| 404 | NotFoundError |
not_found |
| 409 | ConflictError |
machine_not_running, operation_conflict, idempotency_conflict |
| 429 | RateLimitError |
quota_exceeded, rate_limited |
| 5xx | ServerError |
no_capacity, host_unavailable |
spec_not_allowed is a plan or size limit and takes the status of what was refused: 400 for a
size or disk size the plan does not offer, 403 for another idle time
(auto_suspend_after_seconds) on the free plan or starting a paid sandbox larger than its plan
now allows, 409 for restoring a snapshot that does not fit the sandbox's disk.
An operation that ends failed raises OperationFailedError (an APIError carrying the
operation's error). The file transfer calls raise APIError with the codes
above. A wait that runs out raises WaitTimeoutError. A request without a
response (network error, or the client timeout) raises APIConnectionError.
import instabox
try:
instabox.Client(api_key="ibk_wrong").machines.list()
except instabox.AuthenticationError as e:
print(repr(e)) # AuthenticationError(code='malformed_key', message=..., status=401, ...)
# A well-formed key the server does not know is code='invalid_key'.
Development
cd sdk/python
python -m venv .venv && . .venv/bin/activate
pip install -e '.[dev]'
ruff check . && ruff format --check . && mypy --strict src/instabox && pytest
tests/test_live.py mirrors the TypeScript SDK's live test against the real API (create,
exec, files, update, pause, resume, list, delete); it is skipped unless INSTABOX_LIVE=1:
INSTABOX_LIVE=1 INSTABOX_API_KEY=ibk_... pytest -v tests/test_live.py # INSTABOX_LIVE_TEMPLATE=t3 on a paid plan
Publishing
.github/workflows/sdk-python-release.yml publishes sdk/python to PyPI as instabox-sdk when a
merge to main changes the version in pyproject.toml. It authenticates with PyPI trusted
publishing (OIDC), which needs a one-time setup on PyPI by a maintainer with a PyPI account
(the first publish of 0.2.0 failed for want of it):
- On https://pypi.org/manage/account/publishing/, add a pending publisher for the
project name
instabox-sdk(or, once the project exists, a publisher on its Publishing page) with ownerInsForge, repositoryinstabox, workflowsdk-python-release.ymland environmentpypi. - In the GitHub repository, make sure the
pypienvironment exists (Settings → Environments). - Re-run the failed
sdk-python releaserun from Actions (workflow_dispatch), or merge the next version bump.
Without trusted publishing, a PyPI API token in the repository secret PYPI_API_TOKEN
(scoped to the instabox-sdk project) is used instead; leave it unset to use OIDC.
Metadata
Release files for instabox-sdk 0.4.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 | Actions |
|---|---|---|---|---|
| instabox_sdk-0.4.0.tar.gz | 60.1 kB | With provenance | Details for instabox_sdk-0.4.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| instabox_sdk-0.4.0-py3-none-any.whl | Python 3 | none | any | Details for instabox_sdk-0.4.0-py3-none-any.whl |
Total release size: 99.5 kB
Release files / instabox_sdk-0.4.0.tar.gz
| Download URL | instabox_sdk-0.4.0.tar.gz |
|---|---|
| Size | 60.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
bb9a29ee31e98fa083c3d5e81f18b2c78c8076bf5b3f2ce966c2875e7dd517c9
|
|
BLAKE2b-256 checksum How to use checksums |
452b4bc1cb28cbf6583a309473abb4a55805f612ed664ebb3c6ae123c55dd0e4
|
| 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 Nov 6, 2026.
Transparency logRelease files / instabox_sdk-0.4.0-py3-none-any.whl
| Download URL | instabox_sdk-0.4.0-py3-none-any.whl |
|---|---|
| Size | 39.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
e4f2b7f0e0a331bcdf512d5f80c3032ac89e25be1e4897af61bb95abe35037f9
|
|
BLAKE2b-256 checksum How to use checksums |
40f10ee0d7be7bbb661c6eeb94b17a9254f2131bcfeb45d57d6f2787806f7973
|
| 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 Nov 6, 2026.
Transparency logSecurity for instabox-sdk 0.4.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 instabox_sdk-0.4.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 Nov 6, 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.