Skip to main content

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.

PyPI License

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: /workspace remains 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 cwd and 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.files management in Python and CLI, by-name lookup, and canonical volumes=[...] sandbox mounts
  • Port exposure — expose sandbox ports via public URLs
  • PTY sessions — interactive terminal sessions over WebSocket
  • PTY identity — use session_id for reconnect and management; numeric pid remains 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 flock plus traditional fcntl record 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=[...] with ref, mount_path, optional subpath, and read_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. Dockerfile builds use BuildKit. build_backend="native" remains valid for non-Dockerfile declarative/OCI inputs and historical build filters, but is rejected for Dockerfile input.

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",
    tags=["stable"],
    on_log_entry=default_build_logger(),
)

with Sandbox.create(template=build.canonical_ref) 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:

Links

License

Apache-2.0

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

nullspace_sdk-0.1.6.tar.gz (401.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

nullspace_sdk-0.1.6-py3-none-any.whl (188.3 kB view details)

Uploaded Python 3

File details

Details for the file nullspace_sdk-0.1.6.tar.gz.

File metadata

  • Download URL: nullspace_sdk-0.1.6.tar.gz
  • Upload date:
  • Size: 401.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for nullspace_sdk-0.1.6.tar.gz
Algorithm Hash digest
SHA256 eccebc250d0326599d943866dc49143443213965991d49d4446490da007a0db9
MD5 936fe59fd9f18c8a0ac2ed5f6a9d63b5
BLAKE2b-256 bf083359f6d58881841b08748c358fd2009f64b61bba4c433c3efbfb8d98ad4a

See more details on using hashes here.

Provenance

The following attestation bundles were made for nullspace_sdk-0.1.6.tar.gz:

Publisher: publish-pypi.yml on catamaran-research/nullspace

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file nullspace_sdk-0.1.6-py3-none-any.whl.

File metadata

  • Download URL: nullspace_sdk-0.1.6-py3-none-any.whl
  • Upload date:
  • Size: 188.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for nullspace_sdk-0.1.6-py3-none-any.whl
Algorithm Hash digest
SHA256 8b2526f720216ba7747baab18a1d305a51ea32c35d9403566643f5daa13cbb9e
MD5 a338abf4020e855887ecd84e8c924747
BLAKE2b-256 80acebe19296219e2dac9c94621610a8b9885007ba9dd16e8bbe45b82ddfbc6d

See more details on using hashes here.

Provenance

The following attestation bundles were made for nullspace_sdk-0.1.6-py3-none-any.whl:

Publisher: publish-pypi.yml on catamaran-research/nullspace

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page