Repository iconcurepo.dev
sandcastle preview

mattpocock / sandcastle

Orchestrate sandboxed coding agents in TypeScript with sandcastle.run()

7.6k Stars
visibility26 Watchers
fork_right790 Forks
TypeScript
historyUpdated few months ago

description README.md

Sandcastle

What Is Sandcastle?

A TypeScript library for orchestrating AI coding agents in isolated sandboxes:

  1. You invoke agents with a single sandcastle.run().
  2. Sandcastle handles sandboxing the agent with a configurable branch strategy.
  3. The commits made on the branches get merged back.

Sandcastle is provider-agnostic — it ships with built-in providers for Docker, Podman, and Vercel, and you can create your own. Great for parallelizing multiple AFK agents, creating review pipelines, or even just orchestrating your own agents.

Prerequisites

  • Git
  • A sandbox provider — Sandcastle needs an isolated environment to run agents in. Built-in options:
    • Docker Desktop — most common for local development
    • Podman — rootless alternative to Docker
    • Vercel — cloud-based Firecracker microVMs via @vercel/sandbox
    • Or create your own using createBindMountSandboxProvider or createIsolatedSandboxProvider

Quick start

  1. Install the package:
npm install --save-dev @ai-hero/sandcastle
  1. Run npx @ai-hero/sandcastle init. This scaffolds a .sandcastle directory with all the files needed.
npx @ai-hero/sandcastle init
  1. Edit .sandcastle/.env and fill in your default values for CLAUDE_CODE_OAUTH_TOKEN (run claude setup-token on your host to get one). To use an Anthropic API key instead, uncomment and fill in ANTHROPIC_API_KEY.
cp .sandcastle/.env.example .sandcastle/.env
  1. Run the .sandcastle/main.ts (or main.mts) file with npx tsx
npx tsx .sandcastle/main.ts
// 3. Run the agent via the JS API
import { run, claudeCode } from "@ai-hero/sandcastle";
import { docker } from "@ai-hero/sandcastle/sandboxes/docker";

await run({
  agent: claudeCode("claude-opus-4-8"),
  sandbox: docker(), // or podman(), vercel(), or your own provider
  promptFile: ".sandcastle/prompt.md",
});

Sandbox Providers

Sandcastle uses a SandboxProvider to create isolated environments. The sandbox option on run(), interactive(), and createSandbox() accepts any provider, including noSandbox() — opt in to running the agent directly on the host when container isolation is undesired. Built-in providers:

ProviderImport pathTypeAccepted by
Docker@ai-hero/sandcastle/sandboxes/dockerBind-mountrun(), createSandbox(), interactive()
Podman@ai-hero/sandcastle/sandboxes/podmanBind-mountrun(), createSandbox(), interactive()
Vercel@ai-hero/sandcastle/sandboxes/vercelIsolatedrun(), createSandbox(), interactive()
No-sandbox@ai-hero/sandcastle/sandboxes/no-sandboxNonerun(), createSandbox(), interactive()

Worktree methods (wt.run(), wt.interactive(), wt.createSandbox()) accept the same providers as their top-level counterparts. wt.interactive() defaults to noSandbox() when no sandbox is specified.

import { docker } from "@ai-hero/sandcastle/sandboxes/docker";
import { podman } from "@ai-hero/sandcastle/sandboxes/podman";
import { vercel } from "@ai-hero/sandcastle/sandboxes/vercel";
import { noSandbox } from "@ai-hero/sandcastle/sandboxes/no-sandbox";

// Docker, Podman, and Vercel are interchangeable in run() and createSandbox():
await run({
  agent: claudeCode("claude-opus-4-8"),
  sandbox: docker(),
  prompt: "...",
});

// No-sandbox runs the agent directly on the host — accepted by run(),
// createSandbox(), and interactive(). Skips container isolation entirely:
await interactive({
  agent: claudeCode("claude-opus-4-8"),
  sandbox: noSandbox(),
  prompt: "...", // optional — omit to launch the TUI with no initial prompt
  cwd: "/path/to/other-repo", // optional — defaults to process.cwd()
});

You can also create your own provider using createBindMountSandboxProvider or createIsolatedSandboxProvider.

API

Sandcastle exports a programmatic run() function for use in scripts, CI pipelines, or custom tooling. The examples below use docker(), but any SandboxProvider works in its place.

import { run, claudeCode } from "@ai-hero/sandcastle";
import { docker } from "@ai-hero/sandcastle/sandboxes/docker";

const result = await run({
  agent: claudeCode("claude-opus-4-8"),
  sandbox: docker(),
  promptFile: ".sandcastle/prompt.md",
});

console.log(result.iterations.length); // number of iterations executed
console.log(result.iterations); // per-iteration results with optional sessionId
console.log(result.commits); // array of { sha } for commits created
console.log(result.branch); // target branch name

All options

import { run, claudeCode } from "@ai-hero/sandcastle";
import { docker } from "@ai-hero/sandcastle/sandboxes/docker";

const result = await run({
  // Agent provider — required. Pass a model string to claudeCode().
  // Optional second arg for provider-specific options like effort level.
  agent: claudeCode("claude-opus-4-8", { effort: "high" }),

  // Sandbox provider — required. Any SandboxProvider works (docker, podman, vercel, or custom).
  // Provider-specific config (like imageName, mounts) lives inside the provider factory call.
  sandbox: docker({
    imageName: "sandcastle:local",
    // Optional: override the UID/GID used for --user flag (defaults to host UID/GID).
    // Must match the UID baked into the image. Pre-flight check catches mismatches.
    // containerUid: 1000,
    // containerGid: 1000,
    // Optional: mount host directories into the sandbox (e.g. package manager caches)
    // hostPath supports absolute, tilde-expanded (~), and relative paths (resolved from cwd).
    // sandboxPath supports absolute and relative paths (resolved from the sandbox repo directory).
    mounts: [
      { hostPath: "~/.npm", sandboxPath: "/home/agent/.npm", readonly: true },
      { hostPath: "data", sandboxPath: "data" }, // mounts <cwd>/data → <sandbox-repo>/data
    ],
    // Optional: SELinux volume label — "z" (default, shared), "Z" (private), or false (none).
    // No-op on non-SELinux systems (Docker Desktop on macOS/Windows, Linux without SELinux).
    selinuxLabel: "z",
    // Optional: provider-level env vars merged at launch time
    env: { DOCKER_SPECIFIC: "value" },
    // Optional: attach container to Docker network(s) — string or string[]
    network: "my-network",
    // Optional: add the container user to supplementary groups via --group-add.
    // Accepts group names or numeric GIDs (e.g. for a bind-mounted Docker socket).
    groups: ["docker", 999],
    // Optional: expose host devices via --device. Each entry is a full device
    // spec in host[:container[:permissions]] form (e.g. "/dev/kvm").
    devices: ["/dev/kvm"],
    // Optional: limit CPU resources via --cpus. Fractional values allowed (e.g. 1.5).
    // cpus: 2,
  }),

  // Host repo directory — replaces process.cwd() as the anchor for
  // .sandcastle/ artifacts (worktrees, logs, env, patches) and git operations.
  // Relative paths resolve against process.cwd(). Defaults to process.cwd().
  cwd: "../oth