Build Order¶
Sequenced implementation plan. Each step has exit criteria — the next step does not start until they are green. Ordering is deliberate: the two things the whole bet rests on (the contract suite and the docs) are the two things that slip if left last, so they move early.
Step 0 — Lock the P0 decisions (½ day)¶
Nothing is committed until the name is settled, because it is baked into module paths, DSN schemes, the entry-point group, error codes, env vars and docs URLs.
- [x] Q1 name —
sandboxio, short codeSBX(ADR-0014) - [x] Q2 Python floor — >=3.11, dev on 3.14 (ADR-0015)
- [x] Q3 license — MIT + DCO (ADR-0016)
Exit: three ADRs written (0014, 0015, 0016); the name reserved on PyPI; the repo named. Done.
Step 1 — Skeleton and gates, before any feature code (1 day)¶
Gates are cheap now and expensive to retrofit: an accidental eager import spreads fast, and an import budget added in month three is a week of untangling.
- [x]
uvproject,src/layout,py.typed, ruff, pyright strict, mypy strict - [x]
requires-python = ">=3.11"; CI matrix 3.11-3.14, dev default 3.14. Making 3.11 a required check is a branch-protection setting, applied when the repo goes public - [x] CI with the four hard gates already failing closed:
import budget <150 ms · no sockets at import (
pytest-socket) · wheel-contents test · type check · doc-sample parse test · doc-link check (scripts/check_doc_links.py) - [x]
LICENSE(MIT),LICENSE-DOCS(CC BY 4.0, ADR-0026),SECURITY.md,CONTRIBUTING.mdwith DCO and inbound-equals-outbound - [x] PyPI Trusted Publishing, PEP 740 attestations,
CHANGELOG.md— the release workflow is token-free and attesting; the PyPI-side publisher entry is configured at first release - [x]
AGENTS.mdat repo root carrying the Step 0 decisions and the links intospec/ - [x] Issue templates requiring reproduction; stated triage policy
Adapter extras (sandboxio[docker], [e2b], [modal]) are deliberately not declared
yet: an extra that installs nothing is a lie, and each provider SDK choice belongs to the
step that writes its adapter.
Exit: empty package installs, imports in 0.2 ms, gates green on 3.11 and 3.14, and both a deliberately-added eager import and a socket at import turn them red. Done.
AGENTS.mdbelongs here, not at the end. Most of this will be built through coding agents, and without the pinned decisions in context every session re-invents the sync facade.
Step 2 — Core types, zero behaviour (2-3 days)¶
models.py, errors.py, protocols.py, registry.py. No I/O.
Dependencies resolved: Q4 error naming (ADR-0017), Q5 tier ordering (ADR-0018). Unblocked.
- [x] Value objects per spec/01, all frozen with value equality
- [x]
IsolationTierwith its rank map, plus the test asserting every member is ranked - [x] Full error tree with codes, hints and URLs per spec/04; the catalog
is data, and the docs pages generate from it (
scripts/gen_error_catalog.py→errors/) - [x] Protocols per spec/02
- [x] Registry: entry points +
register(), lazy and cached
Decisions taken here that the spec left implicit: ConfigurationError raised directly
carries SBX_E1000; RichOutput and FileInfo got minimal shapes in spec/01;
ExecResult.results is a tuple, like every other sequence in a frozen value object.
Exit: BackendNotFound and BackendNotInstalled raise with the exact install command;
--check on the generated catalog is a pytest gate; import is ~9 ms. Done.
Step 3 — Contract suite first, then FakeBackend (1 week)¶
The suite is written before any adapter, so it specifies behaviour instead of describing whatever Docker happened to do. This is the step that makes everything after it cheap.
Dependencies resolved: Q6 streaming (ADR-0019), Q7 cancellation (ADR-0020), Q9 observability record (ADR-0021). Unblocked.
- [x]
BackendContractSuitecovering the map in spec/08, including the four streaming-cleanup cases, the four cancellation cases, and capability honesty - [x]
FakeBackend+sbx_fakepytest fixture, registered by entry point - [x] The one operation record, redaction at close, and the no-op + queue audit sinks
- [x] Pulled forward because the fake had to be reachable as
create("fake://"): the DSN parser,sandboxio.create()/connect()withrequire_isolation, shielded teardown and request validation shared by every adapter
Decisions taken here: sinks are configured per backend via AuditConfig; a backend that
does not declare NETWORK_POLICY refuses to create at all, since deny-by-default cannot be
honoured; the filesystem family SBX_E1700/SBX_E1701 was added under spec/04's rule 3.
Exit: FakeBackend passes the suite in three configurations (everything declared,
almost nothing declared, UNKNOWN tier); three deliberately broken fakes fail it exactly
where they are broken and nowhere else. Done.
Step 4 — Docker adapter to 100% (1-2 weeks)¶
Dependencies resolved: Q8 sync facade (ADR-0022), Q10 network and
dependencies (ADR-0023), Q11 STATEFUL_CODE
(ADR-0024). Unblocked.
- [x] Full adapter: lifecycle,
run,run_code, streaming, filesystem — shipped as the workspace distributionsandboxio-docker(importsandboxio_docker), which is whatsandboxio[docker]installs; the same shape a third-party adapter takes - [x]
network_mode: noneby default; deny verified against a canary host; non-emptyallowraisesCapabilityNotSupported;STATEFUL_CODEdeclared off - [x] Ryuk-style reaper (one sidecar per process,
SBX_DOCKER_REAPER=0disables); CI asserts zero leaked containers - [x] Sync facade (ADR-0022) landed with the parity test; exercised by behaviour tests against the fake and a smoke test on real Docker, not by the full contract suite — the suite is async, and wrapping sync back into async would not test the cancellation rows honestly. Open point for the suite, not the facade.
Decisions taken here: docker-py in worker threads, never an asyncio-only client (ADR-0002's
trio point); a timeout or cancellation restarts the container because Docker cannot signal
one exec — every process dies, the filesystem survives, and the docs say so; the PID 1 is
sleep <timeout>, which is the provider-side lifetime backstop Docker otherwise lacks;
disk_mb is refused rather than silently ignored.
Threshold: if Docker cannot pass the suite cleanly, fix the abstraction before touching a cloud adapter — a suite bent to fit Docker is worthless for E2B.
Exit: 100% of the suite green on real Docker (locally against OrbStack; the CI job is in place); zero leaked containers; deny-egress proven against a canary host. Done.
Step 5 — E2B adapter (1-2 weeks)¶
The real stress test of the abstraction: rich outputs, PTY, pause/resume, microVM isolation.
- [x] Full adapter as the workspace distribution
sandboxio-e2b; rich outputs surfaced throughExecResult.resultsasRichOutput(mime_type, data)per Jupyter format;STATEFUL_CODEdeclared, every run owning a code context we can restart - [x] PTY and pause/resume stay behind
.nativefor v0.1 - [x] Native exception mapping with
__cause__;AuthErrornamesE2B_API_KEY - [x] Nightly + release CI job, gated on the API key
Decisions taken here: a list[str] command is shlex-quoted into E2B's bash (the SDK
runs shell strings only); a timed-out or cancelled run_code restarts its code context,
the only way to stop a cell; a dropped stream the SDK reports as a timeout is checked
against is_running() and surfaces as SandboxGone when the lifetime ended; CPU and
memory come from the template, so Resources(...) is refused — the suite gained
resource_caps_supported = False for adapters like this.
What the abstraction did not need: no Capability widened, nothing pushed to .native
that the spec had not already put there.
Expect to widen Capability or push things to .native here. Never bend toward the
lowest common denominator (ADR-0003).
Exit: 100% of the suite for declared capabilities against the real service (73 s,
≈60 sandboxes); tests/test_one_line_swap.py runs the same downstream code on fake://,
docker:// and e2b://. Done.
Step 6 — Distribution surface (2 weeks)¶
- [x]
sandboxio doctor(+--json, credential names only, no paid calls; programmaticsandboxio.doctor()→DoctorReport),sandboxio reap(dry run by default; Docker lists stopped containers too;--label,--kill),uvx sandboxio demo(create · run · stream · egress probe · teardown) — spec/10. Console scriptssandboxioand the undocumentedsbxalias;python -m sandboxio. - [x] MCP server
python -m sandboxio.mcpbehindsandboxio[mcp], six tools, one sandbox per process, config fixed at start;docker/mcp/Dockerfile(rootless, E2B backend, no socket) plus the Docker MCP Catalogserver.yaml/tools.json(spec/09). Publication is blocked until v0.1: the image push and the Docker MCP registry PR both need a released version to point at. - [x] LangGraph tool (
sandboxio[langgraph]) and OpenAI Agents tool (sandboxio[openai-agents]), each a native tool object under 100 lines, tested against the fake - [x]
sandboxio/otel.py— GenAI semconv 1.37.0execute_toolspans, zero-config, every attribute string in one file (a test greps for strays);LoggingSinkandFileSinkbesideNoopSink/QueueSink;sandboxio[otel]extra for the API - [x] Diátaxis docs:
quickstart.md,how-to/(Docker with image prep and offline wheelhouse, E2B, offline testing, observability, CI, operations, integrations),explanation/, the generatederrors/,llms.txt+ generatedllms-full.txt(scripts/gen_llms_full.py --checkis a gate), the paste-readyAGENTS.mdsnippet - [x] The same Markdown published at
docs.sandboxio.dev— MkDocs Material, GitHub Pages, strict build,tests/test_docs_site.pyproving everySBX_Ecode resolves at the URL the exception prints (ADR-0029) - [x] Copy-paste GitHub Actions workflow for users in
how-to/ci.md: fake job on PRs, Docker job onmain, leak check - [x]
tests/test_readme_examples.py: every README Python block is executed verbatim — scripts against fakes registered asdocker/e2b, pytest-style blocks through pytester - [x]
examples/: complete runnable programs — hello world, the one-line backend swap, streaming and timeouts, deny-by-default egress, a LangGraph agent and an OpenAI Agents one, andsbx_fakefor the reader's own tools.tests/test_examples.pyexecutes each script against the fakes;examples/test_my_tool.pyis collected by pytest.
Decisions taken here: the CLI is stdlib argparse in core, not the Typer app spec/10 named,
because uvx sandboxio demo must run from the bare distribution and ADR-0004 forbids a new
base dependency; rich is used only for tracebacks under SBX_DEBUG=1 when importable.
Exit codes are 0/1/2/130; doctor exits 1 when an installed backend has a failing check.
uvx sandboxio demo on the bare distribution prints the install command and the
uvx --from "sandboxio[docker]" sandboxio demo form, since that is the stranger's next step.
reap talks to an optional ReapableBackend port (list_managed/kill_managed) and a
ManagedSandbox value object, both added to spec/01–02; every first-party backend
implements it. AuditEvent gained code: str | None, populated only with
AuditConfig(capture_code=True) and redacted; the JSON-line sinks render as_dict().
Semconv has no sandbox vocabulary, so beyond gen_ai.operation.name/gen_ai.tool.name the
span attributes are sandboxio.*. FakeBackend now applies NetworkPolicy to recognised
network calls in run_code (urllib.request.urlopen, requests.get, …) so the demo's
egress probe is honest on fake://. Integrations live in core under
sandboxio.integrations.* with the framework behind an extra; the MCP server translates
SandboxError into the SDK's ToolError so the code and fix reach the model (the SDK hides
other exceptions). SbxSandboxClient stays v0.2.
Exit: every README example runs verbatim — the gate is tests/test_readme_examples.py
(six blocks) — and every program under examples/ runs against the
fakes, the gate being tests/test_examples.py, which also fails on an example missing from
that index. Quickstart timing on this machine, fresh venv, cold uv cache, image already
pulled: uvx --from ".[docker]" sandboxio demo 6.1 s end to end (the demo's own five
steps 2.5 s, create 1.3 s); uvx --from . sandboxio demo on the bare distribution 2.6 s to
the install hint. The MCP image builds and runs as uid 10001 with
python -m sandboxio.mcp as entrypoint; pushing it and the catalog PR wait for the org name.
Docker suite green including reap, zero containers left; E2B suite green including reap,
zero sandboxes left; 3.11 and 3.14 gates green. Done.
v0.1 ship¶
Scope: Docker + E2B + Fake. Modal moves to v0.1.1 (ADR-0025). Two backends fully carry the "swap with one line" narrative; three does not buy a better launch, it buys a later one. Docker↔E2B is also the widest gap in the set, so it stresses the abstraction hardest.
Note: no v0.1 backend represents the GVISOR tier. The tier stays in the enum — it is a
property of the model, not of what shipped — but the docs must not imply Modal is available.
Launch narrative: one secure Python API for running AI-agent code in any sandbox — swap Docker↔E2B with one line; no network by default; test your agent tools offline with the built-in fake. Publish the MCP server to the Docker MCP Catalog the same day.
v0.1.1 — Modal¶
GPU, Volumes with per-tenant sub_path, gVisor tier. Third backend also re-tests the
abstraction against a genuinely different filesystem model.
v0.2¶
- Flight recorder +
sandboxio replay <trace>— record code, file diffs, stdio and timing to a portable trace; deterministic replay; local static HTML viewer. - Egress learning mode —
egress="learn"records attempted domains, emits a paste-ready allowlist. - Per-execution meter —
{duration_ms, cost_usd?, backend}on every result, plussandboxio bench. - Auto dependency inference — PEP 723 / import parsing → uv provisioning, sandbox-gated.
- Pre-execution security lint hook (advisory by default).
- Pydantic AI + CrewAI adapters; OpenAI
SandboxClientadapter. - MCP
search_docs; adapter authoring guide + template repo; shell completions. - Routing file —
sandboxio-routing.yaml, isolation classes androute_for(), already specified in spec/07 and marked deferred there. It was written into the spec at design time and never scheduled; this is where it lands.
v0.3+ — capability-gated and ecosystem¶
Snapshot/fork once ≥2 backends are stable · shadow mode (run on two backends, diff) ·
Daytona and Vercel adapters · K8s agent-sandbox adapter near CRD 1.0 · TUI dashboard ·
sandboxio-server only when its trigger fires
(ADR-0009).
Dependency summary¶
| Step | Blocked by |
|---|---|
| 0 | — |
| 1 | Q1, Q2, Q3 |
| 2 | — (Q4, Q5 decided) |
| 3 | — (Q6, Q7, Q9 decided) |
| 4 | — (Q8, Q10, Q11 decided) |
| 5 | Step 4 exit criteria, in full |
| 6 | — (all decided) |