Skip to main content

nontainer 📦

Versioned, forkable workspaces for code-using agents.

Give any Python agent loop a stateful terminal and Python tool over a workspace that commits files and cache together, forks in O(1), and checks out as a unit. Run locally — where agent code can work through whitelisted live host objects — or on a microVM while the workspace history stays in the state layer.

Think of it as a fake little computer with branchable history, packaged as a library. No Docker, cloud sandbox, or service required for the local default: pip install nontainer.

Status: pre-alpha. Usable and tested end to end; the API will still move before 1.0.

The core

Nontainer keeps three concerns separate:

Responsibility
WorkspaceProvider Where files and cache live, and which history operations are real. The default kvgit provider supplies cheap commits, forks, checkout, and history; other providers declare narrower capabilities rather than pretending equivalence.
Executor Where terminal and Python code run and how they reach workspace state: locally through sandtrap and monkeyfs, or on a real machine through dud.
Adapters How the two tools enter an existing agent loop: the core Python API, an agno toolkit, or an MCP server.

The model-facing surface stays small: a terminal and a run_python tool. Unlike stateless sandbox calls, both are stateful and bound to a session: the shell's cd sticks, files one call writes the next call reads, and a cache dict persists for the whole conversation.

Because that state is a versioned workspace, each state-changing call can be committed as one unit. The host can fork a session in O(1), check out any commit, or audit its history without teaching the agent a version control protocol.

Terminal tool ~33 shell builtins (grep, sed, jq, tar, ...) over the virtual filesystem via termish.
Python tool Policy-gated sandboxed execution via sandtrap; safe stdlib on by default, open()/os/pathlib routed to the workspace via monkeyfs.
In-process Agent code can call your whitelisted host objects -- the live model, the db pool -- under policy. No cloud sandbox can.

What the sandbox is (and isn't). In-process, the Python sandbox (sandtrap) is a walled garden for cooperative LLM-generated code — it gates what agent code can reach (modules, host objects, the filesystem) to an allowlist you control (safe stdlib on by default, everything else opt-in), not a hardened boundary against code trying to escape. That's the right posture for your own agent's code. For crash containment and kernel-enforced defense-in-depth around cooperative code, use isolation="process" / "kernel". For actively untrusted code, or execution exposed to anonymous clients, step off the local model and use DudExecutor()'s microVM backend (see Executors). Full framing in the design notes.

The API in one glance

from nontainer import workspace

ws = workspace("user-42")            # versioned; a kvgit branch per session

ws.terminal("mkdir -p data && echo 'a,b\n1,2' > data/in.csv")
r = ws.run_python("""
import csv
rows = list(csv.reader(open('data/in.csv')))   # sees the shell's file
cache['n_rows'] = len(rows)                      # persists across the session
print(rows)
""")

r.commit                 # this call's commit; ws.checkout(it) restores that state
                         # (the state BEFORE it is the commit before)
ws.files.write("notes.md", "# findings\n")   # host-side write, committed
fork = ws.fork("what-if")    # O(1) branch; the original is untouched
ws.merge("what-if")          # and bring its work back
ws.checkout(r.commit)        # or time-travel to any commit in ws.log()
ws.tags.add("v1")            # name this state; it outlives the call that made it
snap = ws.tags.at("v1")      # a frozen workspace at that name: reads, never writes

The session's own verbs are on the object; the rest are grouped by the seam they belong to — ws.files (read/write/edit/put/get/export), ws.index (the staged set), ws.tags (this session's names), and ws.runtime (how code runs: the executor, commands, shell variables).

A Workspace is one session. The Store is the place those sessions live in, and it owns what outlives one of them — workspace(...) is sugar for opening one out of it:

import nontainer

store = nontainer.store()            # default ~/.nontainer
ws = store.open("user-42")           # what workspace("user-42") does

store.sessions()                     # every session on the store
store.delete("user-42")              # drop one, storage and all
store.tags.add(ws, "v1")             # a name that outlives the session
snap = store.tags.at("v1")           # a frozen workspace at that name

A tag is session-scoped by default and dies with the session; store.tags is the store-scoped half — a publication that outlives it, readable from any session on the store.

Commits cover workspace-owned files and cache. Host-object calls and mounts are external effects: their data is not committed, restored, or copied by a fork. A fork does inherit the mount points, and sees the same live directories behind them.

Files live under the workspace root — /workspace by default (workspace(..., root=)) — and cwd starts there, so relative paths just work. The root is the one absolute-path contract shared across executors: a dud VM mounts its guest workspace at the same path, so /workspace/data/in.csv names the same file whether agent code runs in the local sandbox or a real machine.

Values an agent wants shown go in a ui dict. Anything that can cross as data stays as it is; a live object that cannot — a plotly figure, a DataFrame, a matplotlib figure, a PIL image — is written to <root>/ui/<name>.<ext> and the binding names where it went:

r = ws.run_python("""
import pandas as pd
ui = {"chart": pd.DataFrame({"a": [1, 2, 3]}), "note": "top three"}
""")

r.namespace["ui"]["chart"]        # ArtifactPath('/workspace/ui/chart.table.json',
                                  #              kind='table')
r.namespace["ui"]["chart"].kind   # 'table'  -- derived from the suffix
r.namespace["ui"]["note"]         # 'top three'  -- plain data is untouched
r.ui_problems                     # () -- or why something did not render

ArtifactPath is a str subclass, so knowing about it is optional: code that has never heard of it still gets a working absolute path. Code that cares asks isinstance(v, ArtifactPath) — which a bare string could not answer, since agents put ordinary strings in ui too.

ws.files.read_artifact(path) returns the bytes, or None if it cannot be read — the shape the a2ui envelope wants, so wiring a surface is one argument rather than a hand-rolled wrapper:

turn_to_a2ui(prose, artifacts, ws.files.read_artifact, file_url, surface_id=sid)

This happens in run_python itself, so it is the same on every executor. On a VM the object cannot leave the guest, so it is serialized there; in-process it is serialized here. Either way you get the same binding and the same file.

Adapters are one import away:

from nontainer.adapters.agno import WorkspaceTools   # agno Toolkit
# or:  python -m nontainer.adapters.mcp --session s1  # MCP server (stdio)

Substrates

WorkspaceProvider is the pluggable seam -- one filesystem-and-KV protocol, capability flags instead of pretended equivalence:

Provider versioned cheap_fork sql_audit
kvgit (default) ✅ ✅ O(1) ❌
plain dir ❌ ❌ ❌
AgentFS (spike) ❌ ❌ ✅

kvgit for fork/undo/history, dir when agent code needs real files (C extensions, subprocesses), AgentFS for the one-file-artifact + SQL story -- or bring your own provider. Full guidance in the API reference.

Executors (the [dud] extra)

The second seam. WorkspaceProvider decides where state lives; Executor decides where code runs -- and the two are independent, because the versioning semantics were always properties of the state layer, not the machine.

Executor isolation fidelity
LocalExecutor (default) sandtrap's walled garden; optional process/kernel defense-in-depth emulated shell + filesystem
DudExecutor() -- i.e. backend="vm" a disposable microVM -- vfkit on macOS, firecracker on Linux/KVM real machine
DudExecutor(backend="subprocess") none -- host process real bash, real files
from nontainer.executor_dud import DudExecutor

ws = workspace("user-42", executor_factory=lambda: DudExecutor())

The default "vm" picks the right hypervisor for the host; name "vfkit" or "firecracker" directly if you need to pin one. Asking for one the host can't provide fails closed (IsolationUnavailable) rather than quietly degrading.

Same terminal / run_python tools, same commits, same O(1) forks -- dud receives a tree, executes against a real filesystem, and returns a diff, which the provider commits exactly as it commits a local one. What you buy is fidelity: C extensions, real subprocesses, sqlite on real files, memory-mapped parquet -- the workloads the in-process emulation serves worst.

PythonConfig is honoured or refused on a VM rung, never narrowed. The guest has no network interface at all, so network=False holds by construction and network=True (on the config or a ModuleGrant) raises at open rather than pretending; stdlib=False raises for the same reason, and any isolation is exceeded by the VM. Module grants become the guest image's package list, pinned to the host's installed versions, so what the agent may import is the same set on both rungs (plus the guest's stdlib and those packages' own dependencies); a granted local module with no distribution raises, and vm={"packages_from_grants": False} opts a custom image out. What a real machine changes rather than restricts -- stderr merged into stdout, no tick counts, results crossing as data rather than live objects, cache reads of guest-written keys as bytes, no injected builtins in real bash -- is listed in the executor's docstring.

Note the last row: backend="subprocess" is real bash and real Python with no containment at all -- agent code runs as you, with your network and your files. It enforces none of PythonConfig's policy and refuses only an explicit isolation above "none", which is an ask for containment it cannot give. It buys fidelity, not a boundary, so it's opt-in rather than the default: it's the only backend that needs no hypervisor, which makes it the dev/CI floor. If you want policy gating, crash containment, or kernel defense-in-depth without a VM, use LocalExecutor, not this.

App handlers (the [apps] extra)

Agents author full-stack apps: a no-build frontend plus request handlers -- serverless semantics, not resident servers. A file's path is its route (/workspace/app/api/scores.py → /api/scores), its exported get/post are the verbs. The agent builds and verifies entirely in-loop: a curl builtin hits the dispatcher from the terminal, and test_app runs the app headlessly through Playwright with the workspace as the origin -- no server, no Node. To share it, publish a frozen snapshot: build_router serves the app read-only and concurrently at /apps/{token}/...; mutable app state lives in an external store injected via host_objects, not the (frozen) workspace.

Which frontend the agent reaches for is the embedder's call, not ours: AppsConfig.frontend_notes states the approach and the libraries, and static_assets serves the bytes alongside the app without them entering the workspace. Together they are what a house design system rides on, and what makes an air-gapped deployment work with no CDN in reach. Leave both unset and agents get the built-in guidance -- plain DOM first, Preact and plotly from the CDN allowlist.

Full design -- handler contract, execution model, test_app DSL, serving/threat model: docs/apps.md.

  • Cloud sandboxes (E2B, Daytona, Modal, Fly Sprites): real isolation, real infra. They have persistence; none have history, forking, or in-process host-object access.
  • mcp-run-python (Pydantic): the incumbent local run-python (Pyodide-in-Deno). Stateless per call, no workspace, needs Deno.
  • AgentFS (Turso): SQLite-backed agent FS + KV + SQL-queryable audit, snapshots by file copy. It comes at the problem from storage where nontainer comes from execution -- and nontainer runs on it as one of its backends.
  • Val Town: agents-deploying-endpoints as a polished cloud product (TS). The handler design here is the self-hosted, session-scoped, Python, versioned take on the same instinct.

Part of the agex stack

nontainer composes kvgit, monkeyfs, termish, and sandtrap -- each independently useful, each zero/minimal-dep -- and optionally dud when the little computer should be a real one. agex is the full agent framework over the same substrate; nontainer is the environment layer alone, offered to someone else's loop.

Documentation

  • Quick Start -- first workspace, sandbox config, backends, adapters, the apps loop; runnable examples
  • API Reference -- every class, method, and flag
  • Design notes -- why it's shaped this way (execution model, commit granularity, tool exposure) and what's still ahead
  • Apps design -- handler contract, execution model, test_app, serving/threat model
  • agno sessions -- keeping the agent's conversation in the workspace, so rewind and fork cover memory too
  • Tour -- the whole surface end to end with no LLM: terminal, run_python, an agent commit, a fork with a narrowed view, a merge, the sessions tool, a take, an attachment, a publish
  • Examples -- live agno agents: a data analyst (analyst.py) and a build-and-verify web app (webapp.py)

Install

pip install nontainer            # workspace + terminal + run_python
pip install nontainer[agno]     # + agno Toolkit adapter
pip install nontainer[mcp]      # + MCP server (python -m nontainer.adapters.mcp)
pip install nontainer[apps]     # + handlers/curl, Playwright test_app, serving router
pip install nontainer[agentfs]  # + AgentFS substrate (agentfs-sdk)
pip install nontainer[dud]      # + real-machine / microVM execution (needs 3.11+)

Development

uv sync --extra dev --extra apps --extra agno --extra mcp --extra dud
uv run pytest -q
uv run ruff check nontainer tests && uv run ruff format --check nontainer tests

Include --extra dud on 3.11+: the DudExecutor tests guard themselves with importorskip, so without it the whole real-machine suite skips silently and the run is green for the wrong reason. (On 3.10 the extra installs nothing — it carries a python_version >= "3.11" marker — and those tests skip by design.)

License

MIT

Metadata

Release files for nontainer 0.6.5

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for nontainer 0.6.5
File Size Uploaded
nontainer-0.6.5.tar.gz 781.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for nontainer 0.6.5
File Interpreter ABI Platform
nontainer-0.6.5-py3-none-any.whl Python 3 none any Details

Total release size: 1.1 MB

Release files / nontainer-0.6.5.tar.gz

Download URL nontainer-0.6.5.tar.gz
Size 781.1 kB
Tags Source
SHA-256 checksum
How to use checksums
bd9871f0e2e33737e47b97ef7277deaba4210e651d1c0968cf0b7dcfeace46a6
BLAKE2b-256 checksum
How to use checksums
b4b92c8c31f367d941c329d30a902dfaa06557004a11627f4db00d94fe43f7d2
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.14

Release files / nontainer-0.6.5-py3-none-any.whl

Download URL nontainer-0.6.5-py3-none-any.whl
Size 326.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
e97b8db9719a63b8ac1393c88971db503fc1875c2e445529382d7d17bd044210
BLAKE2b-256 checksum
How to use checksums
a5477876fe8aefaba8a8767dc1a78de5212f45fc798d87243558d6b79af8323b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.14

Release history Release notifications | RSS feed

0.11.5

2 release files

0.11.4

2 release files

0.11.3

2 release files

0.11.2

2 release files

0.11.1

2 release files

0.11.0

2 release files

0.9.1

2 release files

0.9.0

2 release files

0.8.9

2 release files

0.8.8

2 release files

0.8.7

2 release files

0.8.6

2 release files

0.8.5

2 release files

0.8.4

2 release files

0.8.3

2 release files

0.8.2

2 release files

0.8.1

2 release files

0.8.0

2 release files

0.7.11

2 release files

0.7.10

2 release files

0.7.9

2 release files

0.7.8

2 release files

0.7.7

2 release files

0.7.6

2 release files

0.7.5

2 release files

0.7.4

2 release files

0.7.3

2 release files

0.7.2

2 release files

0.7.1

2 release files

0.7.0

2 release files

This release

0.6.5 This release

2 release files

0.6.4

2 release files

0.6.3

2 release files

0.6.2

2 release files

0.6.1

2 release files

0.6.0

2 release files

0.5.2

2 release files

0.5.1

2 release files

0.5.0

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.3.7

2 release files

0.3.6

2 release files

0.3.5

2 release files

0.3.4

2 release files

0.3.3

2 release files

0.3.2

2 release files

0.3.1

2 release files

0.3.0

2 release files

0.2.4

2 release files

0.2.3

2 release files

0.2.2

2 release files

0.2.1

2 release files

0.2.0

2 release files

0.1.2

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page