Skip to main content

Cloud machines for AI agents

Project description

Nullspace

Open-source cloud machines 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

python -m 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:

python -m pip install "nullspace-sdk[cli]"

For Claude Code or Codex local-agent setup, install the MCP extra and then install project-local Nullspace docs:

python -m 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 Machine

with Machine.create() as machine:
    # Run a command
    result = machine.commands.run("echo 'Hello from Nullspace!'", shell=True)
    print(result.stdout)

    # Work with files
    machine.files.write("/hello.txt", "world")
    print(machine.files.read("/hello.txt"))

    # Expose a port
    server = machine.commands.run(
        "python3 -m http.server 8080 --bind 0.0.0.0",
        background=True,
        shell=True,
    )
    try:
        print(machine.get_url(8080))
        input("Open the URL, then press Enter to stop the server and destroy the machine...")
    finally:
        server.kill()

Asynchronous

import asyncio
from nullspace import AsyncMachine


async def main() -> None:
    async with await AsyncMachine.create() as machine:
        result = await machine.commands.run("echo 'Hello from Nullspace!'", shell=True)
        print(result.stdout)

        await machine.files.write("/hello.txt", "world")
        print(await machine.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 machine-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.

Preview URLs

from nullspace import Machine, redact_preview_token, redact_preview_url

machine = Machine.connect("mch_123")

preview = machine.create_signed_preview_url(8080, expires_in_seconds=900)
readiness = machine.wait_for_preview(8080, timeout_secs=30)
print(readiness.ready)
print(redact_preview_url(preview.url))

target = machine.create_preview_proxy_target(
    8080,
    transports=["http", "websocket"],
)
print(target.http_url, target.token_header_name)
print(redact_preview_token(target.http_token))

Preview SDK methods return raw signed URLs and header tokens so callers can open browsers and configure customer-run proxies intentionally. Redact those values before writing terminal logs, app logs, or support bundles. Direct WebSocket previews should use the returned websocket_url; custom preview proxies should forward x-nullspace-preview-proxy-token on every upstream request to Nullspace edge.

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 Machine

with Machine.create() as machine:
    result = machine.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 machine 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, Machine


def on_progress(event) -> None:
    print(event.phase, event.bytes_completed, event.bytes_total, event.transport)


with Machine.create() as machine:
    try:
        machine.files.upload_file(
            "./dist/model.bin",
            "/data/model.bin",
            resumable=True,
            progress=on_progress,
        )
    except FileUploadError as exc:
        if exc.upload_id:
            resumed = machine.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 Machine

with Machine.create() as machine:
    result = machine.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, Machine

with Machine.create() as machine:
    try:
        machine.files.upload_dir("./src", "/data/src")
    except FileUploadError as exc:
        if exc.upload_id:
            resumed = machine.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 machine upload:

nullspace machine upload mch_123 ./dist/app.tar.gz /data/app.tar.gz
nullspace machine upload mch_123 ./src /data/src --exclude '*.pyc'
nullspace machine upload mch_123 - /tmp/stdin.bin

Resumable failures print a concrete next command when the source can be replayed:

nullspace machine upload mch_123 ./big.iso --resume up_123

Use --dry-run to preview local file or directory uploads, and add --json for machine-readable output.

Authentication

For local interactive use, save your API key and API URL with:

nullspace auth login --api-url https://18.140.200.84.sslip.io

For scripts, CI, and coding agents, you can also set credentials as environment variables:

export NULLSPACE_API_KEY=ns_live_...
export NULLSPACE_API_URL=https://18.140.200.84.sslip.io

For the self-hosted single-host appliance in localhost/no-domain mode:

export NULLSPACE_API_KEY="$(sudo cat /etc/nullspace/operator-api-key)"
export NULLSPACE_API_URL=http://localhost

For owned-domain appliance mode, use the configured Caddy/API origin:

export NULLSPACE_API_URL=https://nullspace.example

nullspace auth login 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. Use NULLSPACE_API_URL for SDK and CLI clients; VITE_NULLSPACE_API_BASE is a console build-time variable.

Or pass it directly:

from nullspace import Machine

machine = Machine.create(api_key="ns_live_...")
machine.kill()

Auto-Resume

Use auto_resume=True with paused timeout behavior when a machine should wake on its preview URL after hibernating:

from nullspace import Machine

machine = Machine.create(on_timeout="pause", auto_resume=True)
url = machine.get_url(8080)

After the machine pauses, inbound HTTP or websocket traffic to its preview URL wakes it and the original request is forwarded once the resumed execution is ready. Machine.connect(id) is still an explicit reconnect operation: it resumes a paused machine ID by snapshot route and returns the new running execution. Machine.get_info_by_id(id) is read-only and does not wake paused machines.

Features

  • On-demand machines — 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 machine
  • Persistent volumes — tenant-scoped shared volume objects, direct volume.files management in Python and CLI, by-name lookup, and canonical volumes=[...] machine mounts
  • Port exposure — expose machine ports via direct preview URLs, WebSocket URLs, and customer-run preview proxy targets
  • 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 machines and resume them later
  • Fork — clone a running machine (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 machine 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 Machine, Volume

shared = Volume.create("team-data")
same_shared = Volume.from_name("team-data")

with Machine.create(volumes=[shared.mount("/workspace/shared")]) as machine:
    machine.files.write("/workspace/shared/hello.txt", "persistent state")
    print([attachment.mount_path for attachment in machine.volumes])
    print(same_shared.id)

Direct volume file management uses the same persistent data without starting a machine 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 Machine, 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 Machine.create(template=build.canonical_ref) as machine:
    print(machine.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-1.0.3.tar.gz (1.0 MB view details)

Uploaded Source

Built Distribution

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

nullspace_sdk-1.0.3-py3-none-any.whl (1.6 MB view details)

Uploaded Python 3

File details

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

File metadata

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

File hashes

Hashes for nullspace_sdk-1.0.3.tar.gz
Algorithm Hash digest
SHA256 0f6fca389bf2954f0a81286467d009093971bca95c8e038f8d4c5d19265ab263
MD5 5b2ae139a5571c6d771e659fa6f06187
BLAKE2b-256 f0902fb8a9b4cd743c099c1cdc58b1a63c751c164af57add0690ac5d1a030381

See more details on using hashes here.

Provenance

The following attestation bundles were made for nullspace_sdk-1.0.3.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-1.0.3-py3-none-any.whl.

File metadata

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

File hashes

Hashes for nullspace_sdk-1.0.3-py3-none-any.whl
Algorithm Hash digest
SHA256 b2cc3c3c9b19311632e2ebd027bf4b9e871f7e45a6b02c26e2bc216cdcdb3d8b
MD5 649c76f132156292affa56408ef1012f
BLAKE2b-256 bc2120ce57ed6eabe6ca4893f5cd791b86cd618dfff8f98ef90f941b130ef6a5

See more details on using hashes here.

Provenance

The following attestation bundles were made for nullspace_sdk-1.0.3-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