Cloud sandboxes for AI agents
Project description
Nullspace
Open-source cloud sandboxes for AI agents. Create isolated Linux environments on demand, run commands, read/write files, and expose ports — all from a few lines of Python.
Install
uv pip install "${NULLSPACE_SDK_INSTALL_SPEC:-nullspace-sdk}"
For private beta, NULLSPACE_SDK_INSTALL_SPEC may point at a pinned PyPI
version, a hosted wheel URL, or a private repo tag supplied in the handout.
For CLI usage:
uv pip install "nullspace-sdk[cli]"
For Claude Code or Codex local-agent setup, install the MCP extra and then install project-local Nullspace docs:
uv pip install "nullspace-sdk[cli,mcp]"
nullspace docs install --agent all
Or install from source:
uv pip install -e . # from this directory
uv pip install -e ./sdks/python # from repo root
uv pip install -e ".[cli]" # from this directory, with the CLI
uv pip install -e "./sdks/python[cli]" # from repo root, with the CLI
Docs:
Quickstart
Synchronous
from nullspace import Sandbox
with Sandbox.create() as sandbox:
# Run a command
result = sandbox.commands.run("echo 'Hello from Nullspace!'", shell=True)
print(result.stdout)
# Work with files
sandbox.files.write("/hello.txt", "world")
print(sandbox.files.read("/hello.txt"))
# Expose a port
server = sandbox.commands.run(
"python3 -m http.server 8080 --bind 0.0.0.0",
background=True,
shell=True,
)
try:
print(sandbox.get_url(8080))
input("Open the URL, then press Enter to stop the server and destroy the sandbox...")
finally:
server.kill()
Asynchronous
import asyncio
from nullspace import AsyncSandbox
async def main() -> None:
async with await AsyncSandbox.create() as sandbox:
result = await sandbox.commands.run("echo 'Hello from Nullspace!'", shell=True)
print(result.stdout)
await sandbox.files.write("/hello.txt", "world")
print(await sandbox.files.read("/hello.txt"))
asyncio.run(main())
Use shell=True for normal authored command strings. Use args when you want
exact argument boundaries without shell parsing. The SDK does not infer shell
mode from a plain string, and shell=True cannot be combined with args.
Path contract note:
- No breaking change:
/workspaceremains the default mutable work tree for agent and repo-style flows. - General filesystem APIs also accept valid sandbox-scoped absolute paths such
as
/tmp/...,/data/..., and/srv/app/.... - Endpoint-specific path rules remain explicit: user-controlled
cwdand mount-path inputs still reject reserved runtime paths under/workspace/.nullspace, and/context/...only exists when a source-mount capable surface provides it.
File Uploads
Use write() for in-memory strings and bytes. Use upload_file() or upload()
when the source is a local file path or readable binary stream. Use
upload_dir() or upload() when the source is a local directory path.
from nullspace import Sandbox
with Sandbox.create() as sandbox:
result = sandbox.files.upload_file("./dist/app.tar.gz")
print(result.transport, result.target_path, result.bytes_uploaded)
Without an explicit destination, result.target_path is returned as the
resolved absolute sandbox path. With the default path contract, that is
typically /workspace/app.tar.gz.
The SDK picks direct upload for smaller known-length files and switches to resumable upload for larger files automatically. You can force resumable mode and wire a progress callback:
from nullspace import FileUploadError, Sandbox
def on_progress(event) -> None:
print(event.phase, event.bytes_completed, event.bytes_total, event.transport)
with Sandbox.create() as sandbox:
try:
sandbox.files.upload_file(
"./dist/model.bin",
"/data/model.bin",
resumable=True,
progress=on_progress,
)
except FileUploadError as exc:
if exc.upload_id:
resumed = sandbox.files.resume_upload(
exc.upload_id,
"./dist/model.bin",
progress=on_progress,
)
print(resumed.upload_id, resumed.bytes_uploaded)
else:
raise
Local directory paths use resumable tar upload. upload() dispatches directory
paths to upload_dir() with the default merge conflict policy:
from nullspace import Sandbox
with Sandbox.create() as sandbox:
result = sandbox.files.upload_dir(
"./src",
"/data/src",
ignore_patterns=["*.pyc", "!pkg/__init__.py"],
)
print(result.kind, result.file_count, result.target_path)
Directory uploads honor .nullspaceignore from the source root, append any
explicit ignore_patterns after it, preserve symlinks, and do not implicitly
exclude .git or other dot-directories.
If a resumable directory upload fails mid-transfer, pass the same source
directory back to resume_upload():
from nullspace import FileUploadError, Sandbox
with Sandbox.create() as sandbox:
try:
sandbox.files.upload_dir("./src", "/data/src")
except FileUploadError as exc:
if exc.upload_id:
resumed = sandbox.files.resume_upload(exc.upload_id, "./src")
print(resumed.upload_id, resumed.bytes_uploaded)
else:
raise
CLI Uploads
The bundled CLI now exposes the same upload surface under
nullspace sandbox upload:
nullspace sandbox upload sb_123 ./dist/app.tar.gz /data/app.tar.gz
nullspace sandbox upload sb_123 ./src /data/src --exclude '*.pyc'
nullspace sandbox upload sb_123 - /tmp/stdin.bin
Resumable failures print a concrete next command when the source can be replayed:
nullspace sandbox upload sb_123 ./big.iso --resume up_123
Use --dry-run to preview local file or directory uploads, and add --json
for machine-readable output.
Authentication
Set your API key as an environment variable:
export NULLSPACE_API_KEY=ns_live_...
For the bundled CLI, you can also save an API key with:
nullspace auth login
That writes ~/.nullspace/config.json for backward compatibility. The SDK
accepts explicit api_key= / base_url= arguments; without those, the SDK and
CLI read environment variables, project .env,
~/.config/nullspace/config.json, then legacy ~/.nullspace/config.json.
The legacy config key api_url is accepted as an alias for base_url.
Or pass it directly:
from nullspace import Sandbox
sandbox = Sandbox.create(api_key="ns_live_...")
sandbox.kill()
Auto-Resume
Use auto_resume=True with paused timeout behavior when a sandbox should wake
on its public URL after hibernating:
from nullspace import Sandbox
sandbox = Sandbox.create(on_timeout="pause", auto_resume=True)
url = sandbox.get_url(8080)
After the sandbox pauses, inbound HTTP or websocket traffic to its public URL
wakes it and the original request is forwarded once the resumed execution is
ready. Sandbox.connect(id) is still an explicit reconnect operation: it
resumes a paused sandbox ID by snapshot route and returns the new running
execution. Sandbox.get_info_by_id(id) is read-only and does not wake paused
sandboxes.
Features
- On-demand sandboxes — spin up isolated Linux environments in milliseconds
- Command execution — run shell commands and capture stdout/stderr
- File I/O — read, write, list, and search files inside the sandbox
- Persistent volumes — tenant-scoped shared volume objects, direct
volume.filesmanagement in Python and CLI, by-name lookup, and canonicalvolumes=[...]sandbox mounts - Port exposure — expose sandbox ports via public URLs
- PTY sessions — interactive terminal sessions over WebSocket
- PTY identity — use
session_idfor reconnect and management; numericpidremains for legacy reconnect compatibility - Snapshot & resume — hibernate sandboxes and resume them later
- Fork — clone a running sandbox (unique to Nullspace)
Current Limits
- Volumes are currently a Firecracker-only SDK surface. The Python SDK and bundled CLI expose direct volume file management plus create-time mounts.
- Shared mounts use close-to-open visibility, atomic rename, and
flockplus traditionalfcntlrecord locks; snapshot resume and fork remount them with fresh internal leases before the new sandbox becomes ready. This remounts external shared storage; it does not make Firecracker VM memory or mutable rootfs snapshots portable across incompatible runtime hosts. - The canonical mount shape is
volumes=[...]withref,mount_path, optionalsubpath, andread_only.
Persistent Volumes
from nullspace import Sandbox, Volume
shared = Volume.create("team-data")
same_shared = Volume.from_name("team-data")
with Sandbox.create(volumes=[shared.mount("/workspace/shared")]) as sandbox:
sandbox.files.write("/workspace/shared/hello.txt", "persistent state")
print([attachment.mount_path for attachment in sandbox.volumes])
print(same_shared.id)
Direct volume file management uses the same persistent data without starting a sandbox first:
from pathlib import Path
import tempfile
from nullspace import Volume
shared = Volume.from_name("team-data", create_if_missing=True)
shared.files.make_dir("/datasets")
shared.files.write("/datasets/hello.txt", "hello from direct volume access\n")
with tempfile.TemporaryDirectory() as tmpdir:
local_file = Path(tmpdir) / "artifact.txt"
local_file.write_text("uploaded from local disk\n", encoding="utf-8")
shared.files.upload_file(local_file, "/datasets/artifact.txt")
shared.files.download_file("/datasets/artifact.txt", Path(tmpdir) / "artifact-copy.txt")
shared.files.download_dir("/datasets", Path(tmpdir) / "datasets-copy")
print(shared.files.read("/datasets/hello.txt").strip())
print(shared.files.download_url("/datasets/artifact.txt"))
nullspace volume ls-files team-data /
nullspace volume upload team-data ./dist/model.bin /models/model.bin
nullspace volume download team-data /models/model.bin ./model.bin
nullspace volume download team-data /datasets/frontend ./frontend-copy
nullspace volume download team-data /datasets/frontend ./frontend-copy.tar --archive
See the full guides:
Templates
Template build and logging are Firecracker-only. Docker is not part of the supported long-term contract for this feature.
from nullspace import Sandbox, Template, default_build_logger, wait_for_timeout
builder = (
Template()
.from_ubuntu_image("22.04")
.set_runtime_envs({"HELLO": "Hello from Nullspace!"})
.set_start_cmd(
"echo $HELLO > /tmp/boot.log",
readiness=wait_for_timeout(5_000),
)
)
build = Template.build(
builder,
name="hello-template",
on_log_entry=default_build_logger(),
)
# Equivalent shorthand:
build = Template.build(builder, "hello-template:stable")
with Sandbox.create(template=build.name) as sandbox:
print(sandbox.files.read("/tmp/boot.log").strip())
Use Template.build_in_background(...) plus build.get_status(...) for background builds, and TemplateBuild.connect(...) to reconnect to an existing build.
Ref-based management helpers include Template.get_tags(...), Template.assign_tags(...), and Template.remove_tag(...).
For the full template guide and migration notes, see:
docs/site/guides/python-sdk/templates.mdxdocs/site/guides/python-sdk/template-build-logging-migration.mdx
Links
- Hosted endpoints
- GitHub
- Python SDK Overview
- Python Template Guide
- Python Template Build Migration Guide
- Documentation source
- Changelog
License
Apache-2.0
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file nullspace_sdk-0.1.3.tar.gz.
File metadata
- Download URL: nullspace_sdk-0.1.3.tar.gz
- Upload date:
- Size: 381.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
79b4f8dc855adbe61006500151a61a3277989a04e8431877d5db69b8d2239c32
|
|
| MD5 |
0d3a151821c24b35551e1cfc59626dc1
|
|
| BLAKE2b-256 |
76dd316fa88204488f301c051d7e1d39c932b4f8265464fa46c6b56233052cdd
|
Provenance
The following attestation bundles were made for nullspace_sdk-0.1.3.tar.gz:
Publisher:
publish-pypi.yml on catamaran-research/nullspace
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
nullspace_sdk-0.1.3.tar.gz -
Subject digest:
79b4f8dc855adbe61006500151a61a3277989a04e8431877d5db69b8d2239c32 - Sigstore transparency entry: 1545825576
- Sigstore integration time:
-
Permalink:
catamaran-research/nullspace@d595ef01b71137ff77940348ca4fbd0544f6f1d3 -
Branch / Tag:
refs/tags/sdk-python-v0.1.3 - Owner: https://github.com/catamaran-research
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish-pypi.yml@d595ef01b71137ff77940348ca4fbd0544f6f1d3 -
Trigger Event:
push
-
Statement type:
File details
Details for the file nullspace_sdk-0.1.3-py3-none-any.whl.
File metadata
- Download URL: nullspace_sdk-0.1.3-py3-none-any.whl
- Upload date:
- Size: 177.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
017dc171cf588a26fd314391cd55aea852418f7ecbbe466ce74e962eda2bbc13
|
|
| MD5 |
259973722d077b7fce55a364445f22cc
|
|
| BLAKE2b-256 |
99362c4fb104580ed1408d3362480b7a6dd1d017fce1c7cc1e2055f2e0744082
|
Provenance
The following attestation bundles were made for nullspace_sdk-0.1.3-py3-none-any.whl:
Publisher:
publish-pypi.yml on catamaran-research/nullspace
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
nullspace_sdk-0.1.3-py3-none-any.whl -
Subject digest:
017dc171cf588a26fd314391cd55aea852418f7ecbbe466ce74e962eda2bbc13 - Sigstore transparency entry: 1545825700
- Sigstore integration time:
-
Permalink:
catamaran-research/nullspace@d595ef01b71137ff77940348ca4fbd0544f6f1d3 -
Branch / Tag:
refs/tags/sdk-python-v0.1.3 - Owner: https://github.com/catamaran-research
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish-pypi.yml@d595ef01b71137ff77940348ca4fbd0544f6f1d3 -
Trigger Event:
push
-
Statement type: