Sandcastle: Orchestrate AI Coding Agents in Isolated Sandboxes

Sandcastle: Orchestrate AI Coding Agents in Isolated Sandboxes

Sandcastle gives you a single run() call that boots a Docker container, hands an AI agent a git worktree, and merges the commits back when it's done. It's Matt Pocock's take on the problem of running unattended coding agents without letting them touch your working tree.

Why I starred it

The standard way to run Claude Code AFK is to point it at your repo and hope. The problem: it writes directly to your working directory, can make commits on whatever branch you're on, and has no isolation. If you're parallelizing — running an implementer and a reviewer at once — they're stepping on each other.

Sandcastle solves this with git worktrees as the isolation primitive. Each agent gets a fresh worktree on a dedicated branch. The agent commits there. Sandcastle merges it back. The Docker container bind-mounts the worktree, so no file sync is needed — the agent writes directly through the mount.

What caught me was that the design decision is documented. The docs/adr/ directory has 14 architecture decision records, including one on exactly why they went with worktrees-by-default over the previous "pass a branch strategy flag" API.

How it works

The src/Orchestrator.ts is the heart of the library. It runs a loop from 1 to maxIterations, spinning up a sandbox each time via SandboxFactory. Inside each iteration, it calls invokeAgent(), which shells out to the agent CLI (claude --print --output-format stream-json) and streams the output back line by line.

The stream parsing lives in src/AgentProvider.ts. Each line is parsed as JSON — Claude Code's --output-format stream-json produces newline-delimited events. Sandcastle looks for type === "assistant" blocks with text and tool_use content, extracting display-friendly chunks:

const parseStreamJsonLine = (line: string): ParsedStreamEvent[] => {
  if (!line.startsWith("{")) return [];
  const obj = JSON.parse(line);
  if (obj.type === "assistant" && Array.isArray(obj.message?.content)) {
    for (const block of obj.message.content) {
      if (block.type === "text") texts.push(block.text);
      else if (block.type === "tool_use" && TOOL_ARG_FIELDS[block.name]) {
        events.push({ type: "tool_call", name: block.name, args: block.input[argField] });
      }
    }
  }
  // ...

Only allowlisted tool calls (Bash, WebSearch, WebFetch, Agent) get surfaced to the display — everything else passes through silently.

The idle timeout implementation in invokeAgent() uses Effect's Deferred to race the agent execution against a timeout that fires if no output arrives for N seconds. Three things race: the exec effect, a timeout deferred, and an abort deferred if you pass a signal. The first to resolve wins via Effect.raceFirst. The abort signal path uses Effect.die so the original rejection reason propagates verbatim — no Sandcastle error wrapping.

The prompt system is more interesting than it looks. Shell expressions in prompt files are written as !`command` and are expanded inside the sandbox before the agent sees them, all in parallel:

const results = yield* Effect.all(
  matches.map((match) => {
    const command = match[1]!;
    return Effect.flatMap(
      sandbox.exec(command, { cwd }),
      (execResult) => execResult.exitCode !== 0
        ? Effect.fail(new PromptError({ message: `...` }))
        : Effect.succeed(execResult.stdout.trimEnd()),
    ).pipe(withTimeout(PROMPT_EXPANSION_TIMEOUT_MS, ...));
  }),
  { concurrency: "unbounded" },
);

There's a subtle injection-prevention mechanism: before substituting {{KEY}} arguments, the preprocessor inserts a \x01 marker between ! and the backtick. Only marked shell blocks get executed. Argument values that happen to contain !`...` patterns are treated as inert text. This is buried in src/PromptPreprocessor.ts and isn't mentioned in the README.

The entire runtime is built on Effect-ts. SandboxFactory, Display, AgentStreamEmitter, and SessionPaths are all Effect Context.Tag services. The factory pattern means you can inject a test sandbox that just echoes commands — src/testSandbox.ts does exactly this, which is how the orchestrator tests work without touching Docker at all.

Using it

npm install --save-dev @ai-hero/sandcastle
npx sandcastle init
cp .sandcastle/.env.example .sandcastle/.env
# edit .sandcastle/.env and add your ANTHROPIC_API_KEY

The init scaffolds a .sandcastle/ directory. Basic usage:

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

const result = await run({
  agent: claudeCode("claude-opus-4-6"),
  sandbox: docker(),
  promptFile: ".sandcastle/prompt.md",
  maxIterations: 5,
  completionSignal: "<promise>COMPLETE</promise>",
});

console.log(result.commits); // [{ sha: "abc123" }, ...]
console.log(result.branch);  // "agent/run-1746388800"

The implement-then-review pipeline is where createSandbox() shines — one container, two agents, shared state:

await using sandbox = await createSandbox({
  branch: "agent/fix-42",
  sandbox: docker(),
  hooks: { sandbox: { onSandboxReady: [{ command: "npm install" }] } },
});

const impl = await sandbox.run({
  agent: claudeCode("claude-opus-4-6"),
  promptFile: ".sandcastle/implement.md",
  maxIterations: 5,
});

const review = await sandbox.run({
  agent: claudeCode("claude-sonnet-4-6"),
  prompt: "Review the changes above and fix any issues.",
});

await using handles cleanup via Symbol.asyncDispose. If the worktree is dirty at close time, it's preserved on disk and the path is printed to stderr with cleanup instructions.

Rough edges

The Vercel sandbox provider is listed as supported but the test file (.sandcastle/test-vercel.ts) is a manual script, not an automated test. The CI workflow only runs unit tests that use the local sandbox stub — I didn't see any integration tests against real Docker.

The library is pre-1.0 and the API is still moving. ADR-0003 notes a breaking change to run() shipped as a patch changeset. If you're pinning to this in a script, watch the changelog.

Effect-ts is a full peer dependency, not an implementation detail. The SandboxFactory interface is an Effect Context.Tag, which means if you want to extend the library you're writing Effect code. That's fine if your team knows Effect, a hard sell if they don't.

Documentation is solid for the core API, thin everywhere else. The ADRs are genuinely useful for understanding why the API looks the way it does, but you have to find them.

Bottom line

If you're running Claude Code or Codex agents unattended and want proper isolation without cloud VMs, Sandcastle is the most principled local solution I've seen. The worktree-per-run model is the right abstraction, and the prompt expansion inside the sandbox (rather than on the host) is a detail most comparable tools get wrong.

mattpocock/sandcastle on GitHub
mattpocock/sandcastle