Skip to content
Markdown

Python SDK

One Python client for every Runtime Cloud product, sync and async, method for method.

withruntime needs Python 3.9 or later. The python3 that comes with a Mac is enough. It uses only the standard library. It imports in about 30 ms and keeps its connections open between calls.

Terminalpip install withruntime

This guide describes withruntime 0.45.1. pip show withruntime shows the version you have. A method named here that yours lacks means an older version. Run pip install -U withruntime to update it.

The client finds its key by itself. It uses RUNTIME_API_KEY when that is set. Otherwise, it uses the connection this machine saved. Any npx withruntime command sets up that connection, with one browser approval.

On a server, store a key from https://withruntime.com/account/keys in RUNTIME_API_KEY, from your secret manager. Never put it in source code, a URL or a command-line argument. With no key anywhere, the first call fails with missing_api_key, and the error says how to get one.

One plural word names each product, as in the CLI, the API and MCP. A name renamed on 6 October 2026 keeps working for 30 days after the rename ships. Each use raises a FutureWarning with the new name. After that, it fails with name_retired, and a hint names the new one.

Product Old name
runtime.sandboxes runtime.sandbox
runtime.snapshots runtime.snapshot
runtime.images runtime.image
runtime.volumes runtime.volume
runtime.jobs runtime.job
runtime.domains runtime.domain
runtime.ports runtime.port
runtime.ips runtime.address, runtime.addresses
runtime.networks runtime.network
runtime.networks.wireguard runtime.tunnel

Updated

On this page

Hello, sandbox#

Write a file that totals three invoice amounts, then run it; the total is 750.

Pythonfrom withruntime import Sandboxwith Sandbox.create() as sbx:    sbx.files.write("/workspace/invoice.py", "print(sum([125, 250, 375]))\n")    result = sbx.exec("python3 /workspace/invoice.py")    print(result.exit_code, result.stdout)  # 0, "750\n"

Sandbox.create() takes no required arguments and returns once the sandbox is running. Leaving the with block deletes it, even after an exception; create it with keep=True to stop it instead and keep its disk.

With no arguments you get the free usage while it lasts, the default region, and 2 vCPU, 4 GiB of memory and a 4 GiB disk, running while it works and pausing itself when idle, with no time limit. A paid sandbox can have up to 16 vCPUs and 64 GiB; one without credit, 2 vCPU and 4 GiB.

The free usage is spent first, then prepaid credit, with nothing for you to choose. funding is accepted and ignored. Every field is optional and takes snake_case names:

Pythonfrom withruntime import Runtimeruntime = Runtime()  # RUNTIME_API_KEY, or this machine's connectionsbx = runtime.sandboxes.create(    name="tests-42",    labels={"team": "search", "job": "42"},    vcpu=2,    memory_mib=4096,    disk_mib=8192,    timeout_seconds=900,    on_lease_end="stop",    network={"internet": True, "allow": ["pypi.org", "*.pythonhosted.org"]},)print(sbx.id, sbx.info["funding"], sbx.info["endsAt"])sbx.stop()

A sandbox needs no time limit: it runs while it works and pauses itself when idle, until you stop it or credit or the free usage runs out. timeout_seconds (60 to 86,400, 24 hours) sets a limit when you want one. In that case, it pauses or stops, as on_lease_end says, busy or not. sbx.info["endsAt"] is when it will stop or pause by itself, or None when it never will.

env sets variables for every command, background process, terminal, SSH session and image start command in the sandbox, for its whole life. A command's own env takes precedence over them. Values are never shown again. sbx.info and every later response carry only envNames. update(env=...) changes them. A value sets a variable, and None removes one. The change applies to commands started after it. At most 32 variables and 32 KiB. Forks keep them (API).

Pythonfrom withruntime import Runtimeruntime = Runtime()sbx = runtime.sandboxes.create(env={"QUEUE_URL": "https://queue.example.com", "MODE": "worker"})print(sbx.info["envNames"])  # ['MODE', 'QUEUE_URL']sbx.update(env={"MODE": "drain", "QUEUE_URL": None})sbx.delete()

The async client is the same with await:

Pythonimport asynciofrom withruntime import AsyncRuntimeasync def main():    async with AsyncRuntime() as runtime:        async with await runtime.sandboxes.create() as sbx:            results = await asyncio.gather(*(sbx.exec(f"echo {i}") for i in range(10)))            print([r.stdout.strip() for r in results])asyncio.run(main())

Run commands#

A string runs under bash -c. A list runs the program directly, with no shell. Use a list for untrusted arguments.

A server, a watcher or anything that should keep running goes in spawn, not exec. Everything an exec starts, nohup … & and setsid included, ends when its command returns. A result holds at most 64 KiB of each stream (see below).

Pythonimport osfrom withruntime import Sandboxwith Sandbox.create() as sbx:    sbx.exec("mkdir -p app && echo 'print(1 + 1)' > app/main.py")    run = sbx.exec(        ["python3", "main.py"],        cwd="/workspace/app",        env={"API_TOKEN": os.environ.get("API_TOKEN", "")},        timeout_ms=120_000,    )    if run.exit_code != 0:        print(run.stderr)
  • env is how secrets reach a command. It is never echoed back, and journals record a hash, not the value. Never put a secret in the command line itself.
  • stdin gives the command input, then closes it.
  • user="root" runs the command as root, with no sudo in front: its env, PATH and working directory stay the same and HOME is /root. The default, "sandbox", is the user runtime. spawn and terminal take it too, and a process's user says which it runs as.
  • The default timeout is 60 seconds, or what the sandbox's time limit has left when that is less. When the output streams (on_stdout, on_stderr or exec_stream), the default is 24 hours. The maximum is 24 hours. A command with no timeout_ms is never refused for the time limit. A timeout is a result (timed_out=True, with the output so far), not an exception.
  • check=True raises CommandError on a non-zero exit.
  • A command that the sandbox's out-of-memory killer ended has out_of_memory set on its result and its process. The value holds the code, processes killed, the sandbox's size, a message and a hint. With check=True, CommandError is raised with the code out_of_memory. None means it was not ended for memory (when a program runs out of memory).
  • A result holds at most 64 KiB (65,536 bytes) of stdout and 64 KiB of stderr. The rest is dropped. stdout_truncated or stderr_truncated is True. When nothing was dropped, both are False. For more output, pass on_stdout or on_stderr. A timeout_ms over 60 seconds does the same. The command then streams, and the result keeps everything it printed. Or send the output to a file (cmd > /workspace/out.log), and read it with sbx.files.read_text.

Stream output as it happens with callbacks, or iterate the events:

Pythonimport sysfrom withruntime import Sandboxwith Sandbox.create() as sbx:    sbx.exec("for i in 1 2 3; do echo line $i; sleep 1; done", on_stdout=sys.stdout.write)    for event in sbx.exec_stream("npm --version"):        if event["type"] == "stdout":            print(event["data"], end="")        elif event["type"] == "exit":            print("exit", event["exit_code"])

exec_stream events expose snake_case fields, including process_id, exit_code, timed_out, duration_ms, stdout_truncated, stderr_truncated, dropped_bytes and resume_at, when the event carries that value. The earlier camelCase keys still work, with the same values.

While you read a command's stream, the command waits for you instead of losing output, from its first byte. The sandbox keeps the latest 1 MiB. A reader that is away for more than 10 seconds, while more than that came out, loses the oldest part. That reader gets a truncated event, and the result has both truncation flags set to True. From withruntime 0.7.0, a stream whose connection drops after the command started picks up again from the right byte. If it cannot, it raises ConnectionError with details["processId"], and the command may still be running. Ctrl-C, or cancelling the task, during a streamed exec sends the command SIGTERM.

Background processes#

Pythonfrom withruntime import Sandboxwith Sandbox.create() as sbx:    server = sbx.spawn("python3 -m http.server 8000", cwd="/workspace")    print(server)  # Process(id=..., sandbox_id=..., command=..., state='running', exit_code=None)    repl = sbx.spawn(["python3", "-i", "-q"], stdin="pipe")    repl.write("print(sum([125, 250, 375]))\n")    repl.write("exit()\n", eof=True)    print(repl.wait().stdout)    for process in sbx.processes():        print(process["id"], process["state"], process["command"])    server.kill("SIGTERM")

process.write() tracks accepted input offsets and keeps concurrent writes in order. When the process accepts only part of the input, later chunks match its capacity; when its input pipe is full, the SDK waits briefly before retrying without advancing the offset. An empty EOF write closes input after earlier queued writes finish.

process.output(cursor=0) yields every event from the start until the process exits. A process outlives your connection. Get it back with sbx.process(id). The sandbox keeps each process's latest 1 MiB of output. A reader that was away while more than that came out gets a truncated event saying how many bytes are gone, and wait() sets both truncation flags. A process that prints more than 1 MiB should write to a file.

An interactive terminal#

Pythonfrom withruntime import Sandboxwith Sandbox.create() as sbx:    term = sbx.terminal(cols=120, rows=40)    term.write("echo hello from the terminal\n")    term.write("exit\n")    while (chunk := term.recv()) is not None:        print(chunk.decode(errors="replace"), end="")

Files#

Pythonfrom withruntime import Sandboxwith Sandbox.create() as sbx:    sbx.files.write("/workspace/data/input.csv", "a,b\n1,2\n")    text = sbx.files.read_text("/workspace/data/input.csv")    data = sbx.files.read("/workspace/data/input.csv")  # bytes    print(sbx.files.exists("/workspace/data/input.csv"), sbx.files.stat("/workspace/data/input.csv"))    for entry in sbx.files.list("/workspace", depth=2):        print(entry["type"], entry["size"], entry["path"])    print(sbx.files.glob("**/*.csv"))    sbx.files.mkdir("/workspace/out")    sbx.files.rename("/workspace/data/input.csv", "/workspace/out/input.csv")    sbx.files.remove("/workspace/data", recursive=True)

write makes parent directories and replaces the file atomically. Large files go in parallel chunks, checked by SHA-256. Under /workspace, there is no size limit beyond the disk. Elsewhere, a file is written with the sandbox user's own rights, and can be at most 1 MiB. For a larger file, write it to /workspace, and move it with a command. Whole directories travel as one archive:

Pythonimport pathlibimport tempfilefrom withruntime import Sandboxproject = pathlib.Path(tempfile.mkdtemp())(project / "main.py").write_text("print('hi')\n")with Sandbox.create() as sbx:    sbx.files.upload(str(project), "/workspace/project")    sbx.exec("cd project && python3 main.py > result.txt")    sbx.files.download("/workspace/project", str(project.parent / "project-out"))

An uploaded file keeps its permissions; write(path, data, mode=0o755) sets them (0o644 when left out).

files.archive(path, gzip=True, exclude=None, user="sandbox") returns the folder's tar bytes, gzip-compressed by default. exclude holds plain relative paths, not globs. files.unarchive(path, data, gzip=None, user="sandbox") merges archive bytes into the destination and detects gzip when omitted. Pass user="root" explicitly for root-owned trees; this requires passwordless sudo inside the sandbox.

Pythonfrom withruntime import Sandboxwith Sandbox.create() as sbx:    sbx.files.write("/workspace/project/report.txt", "total: 750\n")    archive = sbx.files.archive("/workspace/project")    sbx.files.unarchive("/workspace/restored", archive)    print(sbx.files.read_text("/workspace/restored/report.txt"))

A directory download keeps the links inside the directory. It raises unsafe_archive for any entry or link that would reach outside it. So nothing in a sandbox can write elsewhere on your machine. The download also checks links against files already in the destination, before publishing. Invalid checksums, malformed path metadata and incomplete tar end markers are refused. Compressed archives are unpacked in bounded pieces. This adds no limit on the total downloaded directory size.

From withruntime 0.7.0, reads are checked. The API sends every file's length before its bytes, and a small file's SHA-256. If a file arrives short, read reads it again. If it is still short, read raises download_incomplete rather than return part of it. read has no size limit. For a file too big to hold in memory, read_stream yields the bytes as they arrive, and raises if they end short. download streams to disk through a partial file. That file is renamed into place only once it is whole:

Pythonwith open("dataset.tar", "wb") as out:    for piece in sbx.files.read_stream("/workspace/dataset.tar"):        out.write(piece)

upload and download of a directory move one tar archive through the API's folder routes. Those routes pack and unpack it with tar inside the sandbox. Any language can call them with a plain PUT or GET (files in the API guide). A downloaded folder is unpacked as it arrives. Its size is limited only by your disk. It lands in place only once it has all arrived.

A sandbox holds at most 4 uploads and 4 downloads at a time. More wait for transfer_limit to clear. Each frees its slot when it ends. A download left unread for a minute gives its slot to the next one.

Watch files

files.watch streams changes under a directory: create, write, remove, rename (with the old path) and chmod. Repeated writes to one file inside a batch come as one event with a count. It is E2B's watch_dir, with filters, and a watch survives a pause: events() stops with exit_reason == "paused", and calling it again after the sandbox wakes carries on from the same place.

Pythonfrom withruntime import Sandboxwith Sandbox.create() as sbx:    watch = sbx.files.watch("/workspace", recursive=True, exclude=["node_modules", ".git/**"])    sbx.exec("echo hi > /workspace/note.txt")    for event in watch.get_new_events(wait_ms=2000):  # or: for event in watch.events()        print(event["type"], event["path"])    watch.stop()

A sandbox runs at most four watches. Each ends after timeout_ms (one hour by default). A flood of changes is capped at 5,000 events a second. Such a flood is reported as an overflow notice in watch.notices, and is never dropped silently.

webhook=True has Runtime read the watch and send its changes to your account's webhooks as sandbox.files.changed events, so nothing has to read it here; timeout_ms=0 keeps it running until stopped. It never keeps a sandbox that pauses itself awake.

Pythonfrom withruntime import Sandboxwith Sandbox.create() as sbx:    watch = sbx.files.watches.start("/workspace/app", recursive=True, webhook=True, timeout_ms=0, id="sync")    sbx.files.watches.stop("sync")

Pause, wake, extend#

Pythonfrom withruntime import Sandboxsbx = Sandbox.create(timeout_seconds=600)  # a ten-minute limitsbx.exec("echo state > /workspace/state.txt")sbx.pause()  # memory and files are kept; compute billing stopsagain = Sandbox.connect(sbx.id)again.wake(timeout_seconds=1200)  # a new limit, from the wakeagain.extend(600)  # moves the limit ten minutes onagain.stop()

A sandbox created without timeout_seconds has no limit, so wake() needs no argument. extend() returns immediately and changes nothing.

pause() returns once the sandbox's processors have stopped, which is where compute billing ends; the host then writes its memory to disk, and a wake or snapshot asked for meanwhile waits for that write.

A paused sandbox also wakes by itself when a request needs it: an exec, a file, process, terminal, desktop or code-interpreter call, or a visit to one of its shared ports. The wake is billed like any wake, from the moment it runs again, and it again runs while it works. A sandbox with a time limit gets a fresh one of its own timeout_seconds, or keeps the one it paused with when that ends later. Turn it off with auto_wake=False at create or sbx.update(auto_wake=False). A call to a paused sandbox then fails with sandbox_paused until you call wake().

A sandbox that paused for being idle also wakes when a process in it is due on a timer, such as a sleep 600 loop (timers). To keep request wakes and turn this off, use wake_on_timers=False at create or sbx.update(wake_on_timers=False).

A sandbox pauses itself after 300 seconds in which nothing happens in it: no request, no command or terminal still running, no open preview, port, SSH or tunnel connection, no network traffic, and its processes using under a fortieth of a vCPU. The next request wakes it. idle_pause_seconds sets the idle time: 60 to 86,400 seconds, or 0 for never. A persistent sandbox has none unless you set it.

Pythonfrom withruntime import Sandboxsbx = Sandbox.create(idle_pause_seconds=600)sbx.update(idle_pause_seconds=1800)  # 0 turns it offsbx.stop()

Keep a sandbox running

A sandbox with no time limit keeps running while it works, with nothing to renew. persistent=True goes further: it keeps a paid sandbox running until you stop it, idle or not, for as long as the account has credit. Its disk is billed and kept, as for any sandbox, so after a stop sbx.restart() starts it again. update(persistent=False) makes it an ordinary sandbox again; delete() removes it. keep_alive() moves a time limit on from your process, for a sandbox created with timeout_seconds, keeping ten minutes ahead, until stop() or the function it returns; on one with no limit it only watches.

Pythonfrom withruntime import Sandboxserver = Sandbox.create(persistent=True, max_total_cost_micros=50_000_000)server.update(persistent=False)  # back to an ordinary sandboxworker = Sandbox.create(timeout_seconds=3600)  # a one-hour limit, moved on belowrelease = worker.keep_alive(margin_seconds=1800)release()worker.stop()

Sandboxes by name#

A name is unique in the account while its sandbox can still run: running, paused, or stopped and persistent. A key in a compartment has its compartment's own names. Sandbox.get_or_create(name) returns that sandbox. It wakes the sandbox if paused, and restarts it if stopped and persistent. Otherwise, it creates one with the fields you pass. sbx.info.get("reused") is True when it found one. A create with a name another sandbox holds fails with name_taken.

Pythonfrom withruntime import Sandboxdev = Sandbox.get_or_create("dev", idle_pause_seconds=900)dev.exec("git pull || true")

Find sandboxes again#

Pythonfrom withruntime import Runtimeruntime = Runtime()for sbx in runtime.sandboxes.list(labels={"team": "search"}, state=["running"]):    print(sbx.id, sbx.info["name"], sbx.state)

Every list returns a page: page.data, page.has_more, page.next_page(), page.to_list(), and a for loop walks every item on every page.

runtime.sandboxes.stop_all(labels={"team": "search"}) stops every live sandbox with all those labels, eight at a time, and returns {"stopped": [...], "failed": [...]}: one failure does not stop the rest. It needs at least one label. runtime.sandboxes.delete_all(labels={"team": "search"}) deletes for good every sandbox with all those labels, stopped ones too, the same way, and returns {"deleted": [...], "failed": [...]}.

Delete a sandbox#

sbx.delete(), or runtime.sandboxes.delete(sandbox_id) without reading it first, removes a sandbox for good in any state: it stops it, deletes its disk and paused memory, revokes its previews and ports, frees its name and takes it out of every list. Its snapshots, usage and audit entries stay. Deleting it again returns the same {"id", "status": "deleted", "deletedAt"}; any other call to it then raises NotFoundError. stop() is the one to use when you may want a persistent sandbox's disk back.

Errors and retries#

Pythonfrom withruntime import NotFoundError, RuntimeAPIError, Sandboxtry:    Sandbox.connect("00000000-0000-4000-8000-000000000000")except NotFoundError:    print("no such sandbox")except RuntimeAPIError as error:    print(error.code, error.hint, error.request_id)

The classes match the JavaScript SDK (no_capacity is a ServiceUnavailableError; no_credit_running_limit and a quota_exceeded on what runs concurrently are a RateLimitError, and a quota_exceeded on what is kept a ConflictError): AuthenticationError, PermissionDeniedError, NotFoundError, ConflictError, InvalidRequestError, RateLimitError, ServiceUnavailableError, AccountBlockedError (from 0.7.0), ConnectionError and CommandError, all subclasses of withruntime.RuntimeAPIError. RuntimeAPIError and RuntimeConnectionError name the SDK errors without hiding Python's built-in RuntimeError and ConnectionError. The old exports remain aliases of the same classes, so existing catches keep working. Every write carries an idempotency key, made for you. Transport failures, and every response that says retryable: true, are retried after its Retry-After, with the same key. So a retry never makes two sandboxes or runs a command twice. A wait over 60 seconds is not slept on. The error is returned immediately, with retry_after_ms. A response that ran nothing (guest_busy, busy, rate_limited) is sent again until the call's deadline. Without a deadline, it is sent again for five minutes, past max_retries. Interrupted complete response bodies are retried with the same key too; streamed output is never replayed after it reaches your code. A failed body read or retry delay carries the original error.idempotency_key.

Pass your own idempotency_key to make a retry safe across process restarts. For 24 hours, a create or a command sent again with the same key and the same input returns the first one's result instead of doing it twice. The same key with different input is refused with idempotency_key_reused.

Pythonfrom uuid import uuid4from withruntime import Runtimejob = uuid4().hex  # your job's own id: keep it for every retry of this jobwith Runtime() as runtime:    with runtime.sandboxes.create(idempotency_key=f"job-{job}-sandbox") as sbx:        sbx.exec(["python3", "-c", "print(42)"], idempotency_key=f"job-{job}-step-1")

A create, wake or restart waits for room. When all four slots without credit are taken (no_credit_running_limit), the account is at its limit (quota_exceeded), or the region is full (no_capacity), sandboxes.create waits. It sends the same request again, for up to two minutes. A burst of CI jobs past the limit queues instead of failing. A sandbox's wake and restart wait the same way when their own server is full (no_capacity). A sandbox stays on its server, so the wake is sent again with the same idempotency key until room frees. A fork never waits.

  • When the wait runs out, the refusal is raised as it came.
  • Set wait_for_capacity (seconds) on the client or on one create, wake or restart. 0 fails immediately, as in sbx.wake(wait_for_capacity=0).
  • From withruntime 0.7.0, on_capacity_wait(refusal, seconds) on one create is called before each wait, to tell a person why nothing has happened yet.
Pythonfrom withruntime import Runtime# A CI job that may queue for ten minutes behind the others.with Runtime(wait_for_capacity=600) as runtime:    with runtime.sandboxes.create() as sbx:        # One create that should fail at once instead.        with runtime.sandboxes.create(wait_for_capacity=0) as now:            print(sbx.id, now.id)        # One that says why it is waiting (withruntime 0.7.0).        with runtime.sandboxes.create(                on_capacity_wait=lambda refusal, seconds: print(refusal.message, "Waiting.")) as told:            print(told.id)

Read-only keys and daily limits#

An owner, admin or developer can make a read-only key and set a daily spending limit on a key, on the API keys page. A key reads both, and it can change neither. runtime.limits needs withruntime 0.3.1 or later:

Pythonfrom withruntime import Runtimewith Runtime() as runtime:    limits = runtime.limits.get()    print(limits["access"])  # "full", "read" or "selected"    left = limits["daily"]["remainingMicros"]    if left is not None and int(left) < 1_000_000:        print("less than $1 left in this 24-hour window")

Past the limit, a create, wake, extension or renewal fails with a RuntimeAPIError whose code is spending_limit_reached (HTTP 402), and it is not retried. A read-only key asking to change anything gets PermissionDeniedError. See security.

Cap one sandbox's whole life with max_total_cost_micros, in millionths of a dollar. When it has cost that much, it stops, and stopReason reads lifetime_cap:

Pythonfrom withruntime import Runtimewith Runtime() as runtime:    # At most 50 cents over this sandbox's whole life.    sbx = runtime.sandboxes.create(max_total_cost_micros=500_000)    sbx.stop()

Secrets your sandboxes never see#

Store an API key once with runtime.secrets, naming the hosts it may go to. Every sandbox of the account then has an environment variable of that name (or env) that holds a placeholder. The host's proxy swaps in the real value on HTTPS requests to those hosts. The value is sealed and never returned:

Pythonimport osfrom withruntime import Runtimewith Runtime() as runtime:    runtime.secrets.set("OPENAI_API_KEY", value=os.environ["OPENAI_API_KEY"],                        hosts=["api.openai.com"])    runtime.secrets.set("GITHUB_TOKEN", value=os.environ["GITHUB_TOKEN"],                        hosts=["api.github.com"], header="Authorization",                        format="token {value}",                        rules=[{"methods": ["GET", "HEAD"], "paths": ["/repos/acme/*"]}])    for secret in runtime.secrets.list():  # names, hosts and placeholders, never values        print(secret["name"], secret["hosts"])    page = runtime.secrets.list(limit=10, after="GITHUB_TOKEN")  # one page, by name    runtime.secrets.delete("GITHUB_TOKEN")

With header, the proxy sets that header on every request to the hosts, with format placing the value. With rules, on accounts with credit, only the requests a rule allows by method and path carry it: a path is exact or ends in /*. Replacing a secret keeps its placeholder, so running sandboxes use the new value. See security for the limits and what is never rewritten.

With env="GITHUB_TOKEN", sandboxes see the placeholder in that variable instead of the secret's name, so secrets scoped (only) to different sandboxes can share one, such as a GITHUB_TOKEN for each repository; a store that would give one sandbox two of them is refused.

Images, volumes and snapshots#

Pythonfrom withruntime import Runtimeruntime = Runtime()image = runtime.images.build(name="data", recipe={"pip": ["pandas"], "apt": ["jq"]},                             on_log=lambda line: print(line["text"]))volume = runtime.volumes.create(size_mib=10_240, name="cache")with runtime.sandboxes.create(image=image["id"],                              volumes=[{"volume_id": volume["id"], "path": "/data"}]) as sbx:    print(sbx.exec("python3 -c 'import pandas; print(pandas.__version__)'").stdout)# A sandbox with volumes cannot be snapshotted, so these use one without.with runtime.sandboxes.create(image=image["id"]) as base:    forks = base.fork(count=2)  # copies as it is now, running    for fork in forks:        fork.stop()    snapshot = base.snapshot(name="with-pandas", retention_days=7)    with runtime.sandboxes.create(snapshot=snapshot["id"]):        pass    runtime.snapshots.delete(snapshot["id"])

images.build waits until the image is ready; images.create queues it and returns. A Dockerfile build takes dockerfile and context_dir, the folder docker build would read, with its .dockerignore; multi-stage builds, COPY --from, ARG and heredocs work. Each build of a name is its next version and takes the tag latest, or the tags you give, and image= on a create takes an id, name, name:tag or name@version. start sets what a sandbox from the image runs and when its create returns ({"command": ..., "ready_port": 8000}). images.versions(name), images.resolve(ref), images.tag(ref, tag), images.untag(ref, tag), images.follow_logs(id, on_log) and images.registries.set(registry, username=..., password=...) for private images do the rest; see custom images. sbx.switch_image("data:v2") moves a running sandbox to a new build, keeping its id, /workspace (its home), volumes, environment and previews; its processes restart and the rest of its old disk is lost (move a sandbox to a new version). A volume lives on one server and is backed up off it daily; volumes.backup(id) and volumes.restore(backup_id) make and restore a backup (storage and backups). sbx.mounts.add(provider="s3", bucket=..., path=..., secret=...), list() and remove(path) mount your own bucket without the sandbox holding its key (mount your own bucket). Images, volumes and kept snapshots are charged as storage (pricing).

Forks and snapshots:

  • Copies run on what the account's new sandboxes run on, as a create does; funding is accepted and ignored.
  • Copies keep the source's size and CPU (reserved CPU, or a raised floor) and are billed as a create with those would be. A copy without credit must fit the limits without credit.
  • The snapshot a fork takes is deleted when the fork ends, whether every copy started or not, and is not billed, unless keep_snapshot=True.
  • If a copy fails, the error's details["startedSandboxIds"] names the copies that did start; they keep running until stopped. A retry with the same idempotency key returns the same error.
  • If a fork stops partway and left its source paused, the source stays paused, and an account notice says so and how to wake it.
  • Copies start on the source's server while it has room, and on another server once the snapshot is copied there when it is full. A kept snapshot is stored on its source server and copied off it, encrypted, after its final compressed form is ready. It survives loss of the server once backedUp is True, meaning that copy has been checked (storage and backups).
  • Snapshots default to mode="memory", keeping files, memory and running processes. base.snapshot(mode="disk") keeps only the root filesystem; each copy boots fresh without the saved processes, at the vcpu, memory_mib and disk_mib you pass (a disk at least the saved size). A copy of a memory snapshot is always the snapshot's size. Both modes pause a running source until capture finishes, then wake it; an already paused source stays paused. A disk snapshot's pause first waits for the sandbox to write out what it holds in memory: a few seconds at most while it writes.

Where deferred compression has been qualified and enabled, a memory snapshot can be "ready" with compressionPending set to True and start copies on the same server before background compression finishes. Temporary raw files are not charged; snapshot billing uses the final verified compressed allocation. The off-server copy waits for that final form, so backedUp is false while compression is pending. See snapshot storage.

If a snapshot was captured but waking its source fails, the error keeps the original failure and details["snapshotId"], details["sourceSandboxId"] and details["sourceWakeError"]. Read that saved snapshot and wake the source explicitly before taking another capture; a failed recovery does not erase the snapshot id.

See JavaScript for what each does; the Python methods are the same in snake_case.

Code interpreter and network rules#

The interpreter runs Python, JavaScript, TypeScript, R, Java, Bash and Go. Each language keeps its state between cells, except Go, which keeps its functions, types and imports and runs each cell as a program. R plots and matplotlib charts come back as PNG images; pandas and R data frames as tables. R, Java and Go are installed in the sandbox the first time you use them (30 to 90 seconds, once), or bake them into an image with apt. A package pip install run from a cell or from sbx.exec makes its imports available in a context that is already running, even in the cell that installed it.

Pythonfrom withruntime import Runtimeruntime = Runtime()with runtime.sandboxes.create() as sbx:    sbx.interpreter.run("import math\nx = math.pi")    cell = sbx.interpreter.run("round(x * 2, 3)")    print(cell["results"][0]["data"]["text/plain"])    frame = sbx.interpreter.run("df <- data.frame(a = 1:3); df", language="r")    print(frame["results"][0]["data"]["application/vnd.runtime.table+json"])    sbx.network.set(internet=True, allow=["pypi.org", "*.pythonhosted.org"])    print(sbx.network.get())

Feedback and support are on the client too: runtime.feedback.submit(...) and runtime.support.message(...); see feedback and support.

Previews and the desktop#

Pythonfrom withruntime import Sandboxwith Sandbox.create() as sbx:    sbx.spawn("python3 -m http.server 3000")    preview = sbx.previews.create(3000)  # private: the link carries its token    print(preview["urlWithToken"])    sbx.desktop.start(width=1280, height=800)  # ready in about a second    sbx.desktop.open("https://example.com")    sbx.desktop.click(640, 400)    with open("screen.png", "wb") as file:        file.write(sbx.desktop.screenshot())    recording = sbx.desktop.recordings.start(fps=10, max_mib=256)    sbx.desktop.type("hello")    sbx.desktop.recordings.stop(recording["id"])    with open("demo.mp4", "wb") as file:        file.write(sbx.desktop.recordings.download(recording["id"]))

A preview is private by default: open urlWithToken, or send token as the x-runtime-preview-token header. visibility="public" gives an address anyone can open, on a paid sandbox only; a sandbox without credit's previews stay private. Within 60 seconds after the sandbox is created or wakes, a request to a port that is not listening yet waits up to 10 seconds for the server to start; after that it returns 502 with x-runtime-preview-error: port_closed.

embed_origins=["https://app.example.com"] on sbx.previews.create names the sites that may show the preview in an iframe (up to 16); any other site's iframe is refused. sbx.previews.rotate(3000) refuses every token issued for the port so far and returns a new one; sbx.previews.delete(3000) stops sharing it. A preview's address is under runtimehost.com, the domain for everything sandboxes serve, kept apart from Runtime's own site. See JavaScript for what each does.

Native previews.create and previews.get accept expires_at as a timezone-qualified ISO timestamp, a minute to a week ahead. It rounds down to a whole second; with ttl_seconds, the earlier deadline wins. A session's expiry also limits its preview reads. Use the returned tokenExpiresAt as the actual deadline. The matching server feature is required: an older server refuses the absolute field rather than falling back to a relative lifetime.

A browser over CDP#

sbx.browser.start() runs Chromium in the sandbox and returns cdpUrl, the private address Playwright's connect_over_cdp, browser-use and Stagehand connect to. Chromium comes from the host, so a start in a new sandbox takes about 1 to 2 seconds and installs nothing:

Pythonfrom withruntime import Sandboxwith Sandbox.create(vcpu=1, memory_mib=2048) as sbx:    cdp_url = sbx.browser.start()["cdpUrl"]    # playwright: browser = p.chromium.connect_over_cdp(cdp_url)

For a page without a browser start, keep spare browsers paused. A paused sandbox is charged only for its storage, and wakes with Chromium running. Run spare.pause() after browser.start(). Then, for each page, run spare.wake(), open a new browser context and the page, and run spare.pause() again. Open each page in its own context so nothing passes between pages. Heavy apps need 2 GiB; 1 GiB suits light pages and scraping at half the memory charge. JavaScript has the same in TypeScript.

A sandbox from a browser#

A session lets your own frontend reach one sandbox directly, without a key in the browser. Make it on your server and hand the page the token and the sandbox id; the page uses them with the JavaScript SDK (Sandbox.fromSession({ token, sandboxId })) or the HTTP API:

Pythonsession = sbx.sessions.create(origins=["https://app.example.com"], ttl_seconds=900)token, sandbox_id = session["token"], session["sandboxId"]

A session runs commands, uses files and reaches previews of that one sandbox, and nothing else. It lasts 1 hour unless asked and 24 hours at most; sbx.sessions.revoke(session_id) ends it immediately, and revoking the key that made it ends all of them. Python code can use one too: Sandbox.from_session(token, sandbox_id). A sandbox from a browser has the rest.

MCP servers in a sandbox#

Start well-known MCP servers (GitHub, Postgres, a Playwright browser, filesystem, fetch, git and more; runtime.mcp.catalog() lists them with their licences) inside a sandbox, and give your agent their URLs:

Pythonfrom withruntime import Runtimeruntime = Runtime()with runtime.sandboxes.create() as sbx:    sbx.mcp.start([        {"id": "github", "secrets": {"GITHUB_PERSONAL_ACCESS_TOKEN": "GITHUB_TOKEN"}},        {"id": "fetch"},    ])    gateway = sbx.mcp.ready()  # every server installed    for server in gateway["servers"]:        print(server["name"], server["url"])    print(gateway["headers"])  # send these with every request

A secret setting names a Runtime secret: the server sees a placeholder, and the egress proxy puts the value into its HTTPS requests to the secret's own hosts. The servers keep to the sandbox's network rules; a host the rules refuse is a warning, never a change.

With public=True the URLs carry no link token and never expire, so an agent can be set up with them once. The header still guards every request and is kept: a start after a restart, or with replace=True, hands out the same one, until stop() forgets it. It needs an account with a kept top-up. A sandbox with no time limit pauses when idle with its servers in memory, and the next request wakes it.

Pythonfrom withruntime import Runtimeruntime = Runtime()box = runtime.sandboxes.get_or_create("mcp", idle_pause_seconds=300)gateway = box.mcp.start([{"id": "fetch"}], public=True, replace=True)print(gateway["servers"][0]["url"], gateway["headers"])  # the same after every start

Domains, TCP ports, addresses, the tunnel and your own proxy#

They need a kept top-up, and a dedicated address credit; see networking.

Pythonfrom withruntime import Runtimeruntime = Runtime()sbx = runtime.sandboxes.create(name="web")domain = runtime.domains.add("app.example.com", sandbox_id=sbx.id, port=3000)print(domain["records"])  # the TXT and CNAME to set; then runtime.domains.verify(...)db = runtime.ports.open(sandbox_id=sbx.id, port=5432)print(db["connect"])  # "203.0.113.10:23456"ip = runtime.ips.reserve()runtime.networks.wireguard.create()office = runtime.networks.wireguard.add_peer("office", routes=["10.0.0.0/16"])print(office["config"])  # a wg-quick fileruntime.networks.upstream_proxy.set("http://proxy.example.com:3128", secret="PROXY_AUTH")

runtime.networks.upstream_proxy, from withruntime 0.12.0 (runtime.network.upstream_proxy from 0.7.0), sends every sandbox's outbound connections through your own proxy; get() and remove() read and undo it. See your own proxy.

Metrics and webhooks#

Pythonfrom withruntime import Runtime, Sandbox, verify_webhooksbx = Sandbox.create()latest = sbx.metrics(range="15m")["latest"]  # CPU and memory, measuredruntime = Runtime()hook = runtime.webhooks.create(url="https://example.com/hooks/runtime")# In the endpoint: verify_webhook(raw_body, headers["runtime-signature"], hook["secret"])print(latest, verify_webhook.__name__)

runtime.events.list() reads lifecycle events and runtime.otel.create() pushes events and metrics to an OpenTelemetry endpoint. See metrics and webhooks.

Account API calls#

Pythonfrom withruntime import Runtimewith Runtime() as runtime:    counts = runtime.usage_requests("7d")    print(counts["calls"], counts["errorPercent"])    for row in counts["operations"]:        print(row["operation"], row["serverErrors"])

usage_requests() defaults to "24h"; "7d", "30d" and "90d" select longer windows. Counts are exact decimal strings, and errorPercent is None when no calls were counted. AsyncRuntime has the same method, awaited. A key needs the usage scope, read-only access or all-products access. See account API calls.

Export settled usage#

Pythonfrom withruntime import Runtimewith Runtime() as runtime:    cursor = None    while True:        page = runtime.usage_export(            "2026-09-01T00:00:00Z", "2026-10-01T00:00:00Z", cursor=cursor        )        print(page["csv"], end="")        cursor = page["nextCursor"]        if cursor is None:            break    print(page["finalThrough"])

The range includes settlements at since and excludes those at until; pending holds are excluded. Concatenate each page's CSV unchanged; the header is on the first only. finalThrough is the time before which no more charges will settle (the range is final once it reaches until), and a row's final is True once its resource is deleted and can be charged nothing more. AsyncRuntime has the same method, awaited. See export settled usage.

Identity tokens#

Inside a sandbox, Sandbox.identity_token("sts.amazonaws.com") returns a short-lived OIDC token naming the sandbox, to trade for AWS or Google Cloud credentials without a stored key. It needs no API key. See identity tokens.

Single sign-on#

An owner sets up single sign-on (SAML or OIDC) and directory sync (SCIM) on the Single sign-on page; both are free. A key made by an owner or admin reads it with runtime.sso.get(): each identity provider, its domain and whether it is verified, whether single sign-on is required, and the directory sync's users and group roles. See single sign-on.

Coming from E2B, Daytona, Vercel Sandbox or Blaxel#

Their supported Python sandbox calls keep the same call shapes on Runtime after changing the import, in withruntime 0.4.0 and later for E2B and 0.5.0 and later for Daytona and Vercel Sandbox, and 0.8.1 and later for Blaxel:

Pythonfrom withruntime.e2b import Sandbox  # was: from e2b import Sandboxfrom withruntime.daytona import Daytona  # was: from daytona import Daytonafrom withruntime.vercel import sandbox  # was: from vercel import sandboxfrom withruntime.blaxel import SandboxInstance  # was: from blaxel.core import SandboxInstancefrom withruntime import modal  # was: import modal (0.23.0 and later)

The adapters translate supported calls to Runtime's native sandbox, with the provider defaults described in each mapping. Unsupported options are refused before allocation, and the mapping names the supported alternatives. To move to Runtime's own calls, see migration.

Configuration#

Pythonfrom withruntime import Runtimewith Runtime(max_retries=4, timeout=120) as runtime:    print(runtime.me()["orgId"])

RUNTIME_API_URL points the client at another API origin. Code inside a Runtime sandbox calls Runtime's API at http://runtime.internal (Runtime's API from inside a sandbox). A client holds at most 48 connections at a time (max_connections), calls and streams such as a command's output together, and keeps 8 of them for calls; past that, calls and streams wait their turn instead of failing. One address may hold 64 connections to the API until it has used a valid key, and more after (limits).

Before 0.3.0 the package was withruntime-cloud, imported as runtime_cloud. That name stopped at 0.5.1 and gets no new releases: install withruntime and import withruntime for everything since.

Try it on your own work. Sign up, then paste the setup prompt into your coding agent.

Start free, no card

Was this page right?