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.
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:
/workspaceremains 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
cwdand 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.filesmanagement in Python and CLI, by-name lookup, and canonicalvolumes=[...]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_idfor reconnect and management; numericpidremains 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
flockplus traditionalfcntlrecord 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=[...]withref,mount_path, optionalsubpath, andread_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:
docs/site/guides/python-sdk/templates.mdxdocs/site/guides/python-sdk/template-build-logging-migration.mdx
Links
- Hosted endpoints
- Self-hosted single-host
- 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-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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0f6fca389bf2954f0a81286467d009093971bca95c8e038f8d4c5d19265ab263
|
|
| MD5 |
5b2ae139a5571c6d771e659fa6f06187
|
|
| BLAKE2b-256 |
f0902fb8a9b4cd743c099c1cdc58b1a63c751c164af57add0690ac5d1a030381
|
Provenance
The following attestation bundles were made for nullspace_sdk-1.0.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-1.0.3.tar.gz -
Subject digest:
0f6fca389bf2954f0a81286467d009093971bca95c8e038f8d4c5d19265ab263 - Sigstore transparency entry: 2084796215
- Sigstore integration time:
-
Permalink:
catamaran-research/nullspace@aaca6e7260c3f73e3016ead61fb3faa1883635fd -
Branch / Tag:
refs/tags/sdk-python-v1.0.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@aaca6e7260c3f73e3016ead61fb3faa1883635fd -
Trigger Event:
push
-
Statement type:
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b2cc3c3c9b19311632e2ebd027bf4b9e871f7e45a6b02c26e2bc216cdcdb3d8b
|
|
| MD5 |
649c76f132156292affa56408ef1012f
|
|
| BLAKE2b-256 |
bc2120ce57ed6eabe6ca4893f5cd791b86cd618dfff8f98ef90f941b130ef6a5
|
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
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
nullspace_sdk-1.0.3-py3-none-any.whl -
Subject digest:
b2cc3c3c9b19311632e2ebd027bf4b9e871f7e45a6b02c26e2bc216cdcdb3d8b - Sigstore transparency entry: 2084796224
- Sigstore integration time:
-
Permalink:
catamaran-research/nullspace@aaca6e7260c3f73e3016ead61fb3faa1883635fd -
Branch / Tag:
refs/tags/sdk-python-v1.0.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@aaca6e7260c3f73e3016ead61fb3faa1883635fd -
Trigger Event:
push
-
Statement type: