Skip to main content
NanoClaw runs agents in isolated Linux containers to provide security through OS-level process and filesystem isolation. The container runtime is abstracted to support multiple backends.

Runtime abstraction

All runtime-specific logic lives in src/container-runtime.ts, making it easy to swap runtimes. Currently supported:
  • Docker (default) - Cross-platform support (macOS, Linux, Windows via WSL2)
  • Apple Container (macOS only) - Lightweight native runtime
The runtime binary is specified by CONTAINER_RUNTIME_BIN in src/container-runtime.ts:
/** The container runtime binary name. */
export const CONTAINER_RUNTIME_BIN = 'docker';

Apple Container vs Docker

Apple Container is Apple’s native virtualization framework (macOS 15+). It runs Linux containers without a VM layer like Docker Desktop, making it lighter weight and faster to start.

When to use Apple Container

  • You’re on macOS 15 (Sequoia) or later
  • You want to avoid installing Docker Desktop (and its licensing)
  • You want faster container startup — no VM boot overhead
  • You’re running NanoClaw on a personal Mac, not a shared server

When to stick with Docker

  • You’re on Linux or Windows (WSL2) — Apple Container is macOS-only
  • You need cross-platform parity — Docker behaves identically across OS
  • You’re deploying to a production server (Linux VPS, Hetzner, etc.)
  • You rely on Docker-specific tooling (Docker Compose, Portainer, etc.)

Key differences

DockerApple Container
Binarydockercontainer
Bind mounts-v host:container:ro--mount type=bind,source=...,target=...,readonly
Stop commanddocker stop -t 1 namecontainer stop name
Health checkdocker infocontainer system status
Orphan cleanupParses docker ps --format JSONParses container ls --format json
Host gatewayBuilt-in on macOS, --add-host on LinuxBuilt-in (host.docker.internal)
PlatformmacOS, Linux, Windows (WSL2)macOS 15+ only

Switching runtimes

Run the /convert-to-apple-container skill in Claude Code:
/convert-to-apple-container
The skill modifies src/container-runtime.ts to change the binary from docker to container and adjusts mount syntax, stop commands, and health check commands accordingly. To revert, use git revert.
Apple Container uses the same container/Dockerfile to build the agent image. If you switch runtimes, rebuild the image with container build instead of docker build.

Container image

The agent container is built from container/Dockerfile and includes:
  • Node.js 24 - Runtime for the agent runner
  • Chromium - Browser automation via agent-browser
  • Claude Code SDK - Installed globally as @anthropic-ai/claude-code
  • System fonts - Emoji and international character support

Dockerfile breakdown

FROM node:24-slim

# Install system dependencies for Chromium
RUN apt-get update && apt-get install -y \
    chromium \
    fonts-liberation \
    fonts-noto-cjk \
    fonts-noto-color-emoji \
    libgbm1 \
    libnss3 \
    # ... additional libraries
    && rm -rf /var/lib/apt/lists/*
The slim Node.js base keeps the image small while providing the necessary runtime.
# Set Chromium path for agent-browser
ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium
ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium

# Install agent-browser and claude-code globally
RUN npm install -g agent-browser @anthropic-ai/claude-code
Browser automation is available to all agents via the agent-browser command.
# Copy package files first for better caching
COPY agent-runner/package*.json ./
RUN npm install

# Copy source code
COPY agent-runner/ ./
RUN npm run build
The agent runner is pre-built during image creation for faster startup.
# Create workspace directories
RUN mkdir -p /workspace/group /workspace/global /workspace/extra \
             /workspace/ipc/messages /workspace/ipc/tasks /workspace/ipc/input

# Set ownership to node user (non-root) for writable directories
RUN chown -R node:node /workspace && chmod 777 /home/node

# Switch to non-root user
USER node

# Set working directory to group workspace
WORKDIR /workspace/group
Containers run as the unprivileged node user (uid 1000) for security.
# Create entrypoint script
RUN printf '#!/bin/bash\nset -e\ncd /app && npx tsc --outDir /tmp/dist 2>&1 >&2\nln -s /app/node_modules /tmp/dist/node_modules\nchmod -R a-w /tmp/dist\ncat > /tmp/input.json\nnode /tmp/dist/index.js < /tmp/input.json\n' > /app/entrypoint.sh && chmod +x /app/entrypoint.sh

ENTRYPOINT ["/app/entrypoint.sh"]
The entrypoint:
  1. Recompiles the agent runner from /app/src (allows per-group customization)
  2. Reads input JSON from stdin
  3. Executes the agent runner with the input

Building the image

Rebuild the container image after modifying the Dockerfile or agent-runner:
./container/build.sh
The container buildkit caches the build context aggressively. --no-cache alone does NOT invalidate COPY steps. To force a clean rebuild, prune the builder:
docker builder prune -af
./container/build.sh

Container lifecycle

Spawning containers

Containers are spawned by the runContainerAgent function in src/container-runner.ts:
export async function runContainerAgent(
  group: RegisteredGroup,
  input: ContainerInput,
  onProcess: (proc: ChildProcess, containerName: string) => void,
  onOutput?: (output: ContainerOutput) => Promise<void>,
): Promise<ContainerOutput>
1

Build volume mounts

The host determines which directories to mount based on group privileges (main vs. non-main) and the group’s containerConfig.additionalMounts.
2

Generate container name

Each container gets a unique name: nanoclaw-{group}-{timestamp}
3

Spawn container process

const container = spawn(CONTAINER_RUNTIME_BIN, containerArgs, {
  stdio: ['pipe', 'pipe', 'pipe'],
});
The container runs with stdin/stdout/stderr piped to the host.
4

Pass input via stdin

container.stdin.write(JSON.stringify(input));
container.stdin.end();
The input JSON contains the prompt, session ID, group folder, and metadata. Credentials are handled by the secret injection layer (OneCLI gateway or credential proxy) — never passed via stdin or mounted as files.
5

Stream output

The host parses output markers (---NANOCLAW_OUTPUT_START--- and ---NANOCLAW_OUTPUT_END---) from stdout and calls the onOutput callback for each complete message.
6

Handle completion

When the container exits, logs are written to groups/{name}/logs/container-{timestamp}.log and the promise resolves with the final output. Error logs include only input metadata (prompt length and session ID) rather than the full prompt to avoid persisting user conversation content on disk.

Container arguments

From the buildContainerArgs function in src/container-runner.ts:
async function buildContainerArgs(
  mounts: VolumeMount[],
  containerName: string,
  agentIdentifier?: string,
): Promise<string[]> {
  const args: string[] = ['run', '-i', '--rm', '--name', containerName];

  // Pass host timezone so container's local time matches the user's
  args.push('-e', `TZ=${TIMEZONE}`);

  // OneCLI gateway handles credential injection
  const onecliApplied = await onecli.applyContainerConfig(args, {
    addHostMapping: false,
    agent: agentIdentifier,
  });
  if (!onecliApplied) {
    logger.warn({ containerName }, 'OneCLI gateway not reachable');
  }

  // Run as host user so bind-mounted files are accessible
  const hostUid = process.getuid?.();
  const hostGid = process.getgid?.();
  if (hostUid != null && hostUid !== 0 && hostUid !== 1000) {
    args.push('--user', `${hostUid}:${hostGid}`);
    args.push('-e', 'HOME=/home/node');
  }

  for (const mount of mounts) {
    if (mount.readonly) {
      args.push(...readonlyMountArgs(mount.hostPath, mount.containerPath));
    } else {
      args.push('-v', `${mount.hostPath}:${mount.containerPath}`);
    }
  }

  args.push(CONTAINER_IMAGE);
  return args;
}
Key flags:
  • -i - Interactive (keeps stdin open)
  • --rm - Remove container after exit (ephemeral)
  • --name - Unique container name for management
  • --user - Run as host UID/GID for file permissions
  • OneCLI SDK configures container networking for credential injection (no explicit env vars)

Volume mounts

Mounts are built per-group in src/container-runner.ts:
PathMain GroupNon-Main GroupMode
Project root/workspace/projectNot mountedRead-only
Group folder/workspace/group/workspace/groupRead-write
Global memoryVia project mount/workspace/global (if exists)Read-only
Claude sessions/home/node/.claude/home/node/.claudeRead-write
IPC namespace/workspace/ipc/workspace/ipcRead-write
Agent runner src/app/src/app/srcRead-write
Additional mountsConfigurableConfigurablePer-config
Each group gets its own copy of the agent-runner source in data/sessions/{group}/agent-runner-src/, allowing per-group customization without affecting other groups.

Timeouts

Containers have two timeout mechanisms:
  1. Hard timeout - Maximum runtime before force kill (default: 30 minutes 30 seconds with default settings)
  2. Idle timeout - Graceful shutdown after period of inactivity (default: 30 minutes)
From the timeout logic in src/container-runner.ts:
const configTimeout = group.containerConfig?.timeout || CONTAINER_TIMEOUT;
const timeoutMs = Math.max(configTimeout, IDLE_TIMEOUT + 30_000);

const killOnTimeout = () => {
  timedOut = true;
  logger.error(
    { group: group.name, containerName },
    'Container timeout, stopping gracefully',
  );
  exec(stopContainer(containerName), { timeout: 15000 }, (err) => {
    if (err) {
      container.kill('SIGKILL');
    }
  });
};

let timeout = setTimeout(killOnTimeout, timeoutMs);

// Reset timeout on activity (streaming output)
const resetTimeout = () => {
  clearTimeout(timeout);
  timeout = setTimeout(killOnTimeout, timeoutMs);
};
The hard timeout is calculated as Math.max(configTimeout, IDLE_TIMEOUT + 30_000), ensuring a 30-second gap between idle shutdown and the hard kill. With default settings (both 30 minutes), the hard timeout is 30 minutes 30 seconds, giving the _close sentinel time to trigger graceful shutdown before the hard kill fires.

Runtime management

Ensuring runtime is available

On startup, NanoClaw verifies the container runtime is accessible via ensureContainerRuntimeRunning in src/container-runtime.ts:
export function ensureContainerRuntimeRunning(): void {
  try {
    execSync(`${CONTAINER_RUNTIME_BIN} info`, {
      stdio: 'pipe',
      timeout: 10000,
    });
    logger.debug('Container runtime already running');
  } catch (err) {
    logger.error({ err }, 'Failed to reach container runtime');
    // Display error banner
    throw new Error('Container runtime is required but failed to start');
  }
}

Cleaning up orphans

Orphaned containers from previous runs are cleaned up at startup via cleanupOrphans in src/container-runtime.ts:
export function cleanupOrphans(): void {
  try {
    const output = execSync(
      `${CONTAINER_RUNTIME_BIN} ps --filter name=nanoclaw- --format '{{.Names}}'`,
      { stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8' },
    );
    const orphans = output.trim().split('\n').filter(Boolean);
    for (const name of orphans) {
      try {
        execSync(stopContainer(name), { stdio: 'pipe' });
      } catch {
        /* already stopped */
      }
    }
    if (orphans.length > 0) {
      logger.info(
        { count: orphans.length, names: orphans },
        'Stopped orphaned containers',
      );
    }
  } catch (err) {
    logger.warn({ err }, 'Failed to clean up orphaned containers');
  }
}

Output streaming

Containers use sentinel markers to enable robust streaming output parsing:
  • ---NANOCLAW_OUTPUT_START--- - Marks the beginning of a JSON output block
  • ---NANOCLAW_OUTPUT_END--- - Marks the end of a JSON output block
From the stdout handler in src/container-runner.ts:
container.stdout.on('data', (data) => {
  const chunk = data.toString();

  // Stream-parse for output markers
  if (onOutput) {
    parseBuffer += chunk;
    let startIdx: number;
    while ((startIdx = parseBuffer.indexOf(OUTPUT_START_MARKER)) !== -1) {
      const endIdx = parseBuffer.indexOf(OUTPUT_END_MARKER, startIdx);
      if (endIdx === -1) break; // Incomplete pair, wait for more data

      const jsonStr = parseBuffer
        .slice(startIdx + OUTPUT_START_MARKER.length, endIdx)
        .trim();
      parseBuffer = parseBuffer.slice(endIdx + OUTPUT_END_MARKER.length);

      try {
        const parsed: ContainerOutput = JSON.parse(jsonStr);
        if (parsed.newSessionId) {
          newSessionId = parsed.newSessionId;
        }
        hadStreamingOutput = true;
        resetTimeout(); // Reset timeout on activity
        outputChain = outputChain.then(() => onOutput(parsed));
      } catch (err) {
        logger.warn(
          { group: group.name, error: err },
          'Failed to parse streamed output chunk',
        );
      }
    }
  }
});
This allows the host to receive and act on agent output in real-time, rather than waiting for the container to exit.

Debugging containers

docker ps --filter name=nanoclaw-
docker logs nanoclaw-main-1234567890
docker inspect nanoclaw-main-1234567890 | jq '.[0].Mounts'
docker exec -it nanoclaw-main-1234567890 /bin/bash
docker stop -t 1 nanoclaw-main-1234567890
cat groups/main/logs/container-2026-02-28T12-00-00-000Z.log
Logs include:
  • Input summary (prompt length and session ID) — full prompt only at verbose level
  • Container arguments
  • Volume mounts
  • Stdout/stderr (when verbose logging is enabled)
  • Exit code and duration
Last modified on March 24, 2026