Architecture

June 23, 2026 · View on GitHub

Tupper is layered so that one abstraction (@tupper/core) is shared by many backends and many consumers.

flowchart TB
    core["@tupper/core<br/>interfaces · types · schemas · registry"]

    subgraph backends["Backends — implement SandboxBackend, self-register on import"]
        container["@tupper/container ✅"]
        firecracker["@tupper/firecracker 🧪"]
        wsl["@tupper/wsl 🔜"]
    end

    sdk["@tupper/sdk<br/>E2B-style Sandbox facade"]
    consumers["@tupper/cli · api · mcp · deepagents · mastra"]

    container -. registers .-> core
    firecracker -. registers .-> core
    wsl -. registers .-> core
    sdk --> core
    consumers --> sdk

@tupper/core

Core's only runtime dependency is Zod (for the shared validation schemas in schemas.ts). It defines two interfaces every backend implements:

  • SandboxBackend — a runtime that provisions sandboxes: name, isAvailable(), create(opts?), connect(id), list().
  • SandboxInstance — one sandbox: id, backend, execute(command, opts?), writeFiles(files), readFiles(paths), info(), setTimeout(ms), kill(), getHost(port).

execute is the universal primitive — it matches the deepagents BaseSandbox contract, and higher-level file operations in the SDK build on it. The interface is deliberately the union of what the deepagents, Mastra, and E2B-style consumers need, so adapters stay thin.

Backend resolution

Core never statically imports a backend. resolveBackend(options?) selects one in this order:

flowchart TD
    start([resolveBackend]) --> named{explicit backend?}
    named -- "registered name" --> use([use it])
    named -- "package specifier" --> imp[import + self-register] --> use
    named -- no --> avail{registered and available?}
    avail -- yes --> use
    avail -- no --> plat[lazy-import platform default]
    plat --> ok{available?}
    ok -- yes --> use
    ok -- no --> err[[throw BackendUnavailableError]]

The platform defaults are darwin → @tupper/container, linux → @tupper/firecracker, win32 → @tupper/wsl. Backends self-register on import by calling registerBackend(...), so installing a backend package is all it takes to make it selectable.

The injectable loader

The lazy import() resolves relative to the module that performs it. If core did the import, the specifier would resolve against core's node_modules — which breaks in symlinked workspaces where core doesn't depend on the backends. So resolveBackend({ load }) accepts a loader, and @tupper/sdk passes its own (s) => import(s). The specifier then resolves against the SDK's node_modules, where backends are declared as optional peer dependencies. This works in both symlinked monorepos and flat npm installs.

Runtime portability

All package source uses Node built-ins only — node:child_process (spawn with an argv array, never a shell), node:fs/promises, node:os, node:path. There are no Bun.* runtime APIs, so the libraries run unchanged on Node and Bun. Bun is used only as dev/test tooling.

Errors

TupperError is the base class. Subclasses: BackendUnavailableError, SandboxNotFoundError, CommandTimeoutError.