Deep Agents
AgentContextOrchestratorRetrievalText2SQLToolbox

Architecture: Sandbox

Engine bridge, binary bridges internals, and the integration pipeline

This page explains the internal architecture of the sandbox system. Read this if you want to understand how the pieces fit together, extend the system with a custom engine or strategy, or debug issues.

Engine Bridge + Strategies

The container-backed sandboxes combine two patterns on orthogonal axes, so Docker and Apple Container share one strategy family — only the dialect differs:

  • Bridge (dialect axis). A ContainerEngine<TOpts> captures everything that differs between container runtimes — the CLI binary, the run/exec/build/ inspect argv, volume-mount syntax, status parsing, error detection, workdir bootstrap, name-collision recovery, and the installer context. There is one engine per backend (dockerEngine, appleEngine).
  • Template Method (mode axis). A single ContainerSandboxStrategy<TOpts> owns the shared lifecycle (resolve an image, prepare volumes, start a long-running container, configure it, then return the common sandbox methods) and delegates every CLI dialect to its injected engine. It is typed to CommonSandboxOptions, so the skeleton can never read an engine-only knob (Docker's security/network; Apple's arch). Subclasses pick the mode.
ContainerEngine<TOpts>            ← dialect seam (one impl per backend)
├── dockerEngine                  — docker CLI
└── appleEngine                   — container CLI (per-container VMs, virtiofs)

ContainerSandboxStrategy<TOpts>   ← shared skeleton, engine-injected
├── RuntimeStrategy               — install packages/binaries at runtime
└── ContainerfileStrategy         — build image from a Dockerfile (cached)

ComposeStrategy                   — Docker-only; not part of the bridge

Template Method Flow

The base strategy defines the creation algorithm and delegates each CLI dialect to engine.*. Subclasses override only getImage() and configure():

create()

  ├─ 1. getImage()           ← mode-specific
  │     ├─ Runtime:       return image name (or engine.defaultImage)
  │     ├─ Containerfile:  engine.buildImage(...) if needed, return cached tag
  │     └─ Compose:       return '' (compose manages images)

  ├─ 2. prepareVolumes()     ← common: validate bind paths, inspect/create volumes

  ├─ 3. startContainer()     ← common: spawn engine.runArgs(...) with --rm
  │     └─ Compose override:  docker compose up -d

  ├─ 4. engine.ensureWorkdir()  ← engine: no-op on Docker; `mkdir -p` on Apple

  ├─ 5. configure()          ← mode-specific
  │     ├─ Runtime:       run installers via engine.createInstallerContext()
  │     ├─ Containerfile:  no-op (image already configured)
  │     └─ Compose:       no-op (compose file defines everything)

  └─ 6. createSandboxMethods()  ← common: return { executeCommand, spawn, readFile, writeFiles, dispose }

If configure() throws, the base class auto-stops the container before re-throwing.

The methods object returned by createSandboxMethods() also implements [Symbol.asyncDispose], which delegates to dispose(). Because DisposableSandbox extends AsyncDisposable, callers can bind any backend with await using and the container is torn down at scope exit.

Container Startup

All single-container strategies start a long-running container and then execute commands inside it. Docker uses docker run; Apple Container uses container run with equivalent name, resource, volume, environment, arch, and command controls where the Apple CLI supports them.

docker run -d --rm \
  --name sandbox-<uuid> \
  --memory=1g --cpus=2 \
  -w /workspace \
  --mount type=bind,src=/host/path,dst=/container/path,readonly \
  <image> [command ...]

Command resolution for Runtime and Dockerfile strategies:

  • command omitted (default): append tail -f /dev/null as a keep-alive.
  • command: null or command: []: append nothing; image/Dockerfile CMD/ENTRYPOINT run as declared.
  • Non-empty command: append verbatim, overriding image/Dockerfile CMD.

Commands then execute via docker exec <id> sh -c "<command>".

Apple Container follows the same shape with container run --detach --rm ... and executes commands via container exec <id> sh -c "<command>". It does not support Compose, Docker volume drivers, or Docker security/network flags; instead each container runs inside its own lightweight VM and exposes a smaller set of resource, arch, bind-mount, named-volume, and environment controls.

Dockerfile Image Caching

The shared ContainerfileStrategy generates a deterministic image tag from the Dockerfile content plus the build identity (Docker's platform / Apple's arch), then asks the engine to build only on a cache miss:

Dockerfile content + identity → SHA-256 → first 12 chars → "sandbox-a1b2c3d4e5f6"

Same Dockerfile + identity produces the same tag. The strategy checks engine.imageExists(tag) and calls engine.buildImage(...) only when it is missing; Docker's layer cache handles docker build, and Apple Container skips container build when the tag already exists locally.

Compose Overrides

ComposeStrategy overrides three base class methods:

MethodStandardCompose Override
startContainer()docker rundocker compose up -d
exec()docker exec <id>docker compose exec -T <service>
stopContainer()docker stopdocker compose down

Binary Bridges

Binary bridges connect just-bash virtual environments to real host binaries. They solve three path resolution problems:

Virtual CWD to Real CWD

just-bash uses virtual paths like /home/user. Binary bridges resolve them to real host paths:

ReadWriteFs:  root + cwd → path.join(fs.root, ctx.cwd)
OverlayFs:    fs.toRealPath(ctx.cwd)
InMemoryFs:   fallback to process.cwd()

Virtual PATH to Real PATH

just-bash sets PATH=/bin:/usr/bin which doesn't include host binary locations (nvm, homebrew, etc.). Binary bridges always use process.env.PATH for binary resolution.

File Argument Detection

Arguments that look like file paths are resolved relative to the real CWD:

Detected as path:     Has file extension (.md, .py), contains /, starts with .
Passed through:       Starts with - (flags), no path indicators

Security via allowedArgs

createBinaryBridges({
  name: 'git',
  allowedArgs: /^(status|log|diff|show)/,
});

// Allowed:  git status, git log, git diff
// Blocked:  git log --oneline (flags are also tested), git reset --hard

Each argument is tested individually against the regex. Any non-matching argument returns exit code 1 with a security policy error.

Integration Pipeline

To give an AI agent a bash tool that runs inside a sandbox, compose the two independent systems explicitly at the call site:

1. createDockerSandbox(sandboxOptions)
   └─ Returns: DisposableSandbox { executeCommand, spawn, readFile, writeFiles, dispose }

2. createBashTool({ sandbox, ...bashOptions })
   └─ Returns: { bash, tools, sandbox }

Skill provisioning and discovery are application concerns. After making files available through the backend, pass explicit AvailableSkill[] metadata to the skills() context fragment; createBashTool does not inspect those files.

Any DisposableSandbox matches what createBashTool expects from its sandbox parameter (executeCommand(command, options?) → { stdout, stderr, exitCode }). Backends that can expose unbuffered process streams, including Docker, Apple Container, Microsandbox, Agent OS, and Daytona, also implement optional spawn(command, options?) for streaming stdout/stderr. This is why real process backends can be plugged directly into the shared, owned createBashTool from @deepagents/context.

Decorator Chain Behavior

createBashTool layers decorators over the backend sandbox. The decorators preserve spawn when the backend exposes it:

  • The bash tool forwards AI SDK's native executionOptions.abortSignal to executeCommand as options.signal.
  • observeSandboxFileEvents snapshots before and after spawn to record write/modify/delete events under destination, and delays exit resolution until the post-spawn snapshot is captured.

File Operations: Base64 Encoding

readFile and writeFiles use base64 encoding internally because buffered executeCommand uses CLI process helpers that must preserve binary-safe content across host/container boundaries:

readFile:   <backend> exec <id> sh -c 'base64 "/path/to/file"'  → decode on host
writeFiles: echo "<base64>" | base64 -d > "/path/to/file"    → decode in container

spawn bypasses nano-spawn and uses raw child-process streams so callers get byte-accurate live output and structured exit metadata.

Extending

There are two extension points, one per axis:

Add a backend → implement ContainerEngine. Provide the CLI dialect (run/ exec/build args, status parsing, error detection, installer context) plus a factory that wires the engine into the shared strategies. No strategy subclassing needed — this is how appleEngine works.

import {
  ContainerfileStrategy,
  RuntimeStrategy,
  type ContainerEngine,
  type CommonSandboxOptions,
} from '@deepagents/context';

const podmanEngine: ContainerEngine = {
  cli: 'podman',
  runArgs: (image, id, opts, workdir) => [/* podman run argv */],
  // execArgs, inspectArgs, mountArg, parseStatus, buildImage,
  // createInstallerContext, errors, ... (the full dialect seam)
};

export function createPodmanSandbox(options: CommonSandboxOptions = {}) {
  return new RuntimeStrategy(options, podmanEngine, {
    image: options.image ?? podmanEngine.defaultImage,
    installers: options.installers ?? [],
  }).create();
}

Add a creation mode → subclass ContainerSandboxStrategy. Override getImage() / configure() (and any lifecycle method, as ComposeStrategy does) while still delegating the CLI dialect to the injected engine.

Next Steps

On this page