A TypeScript library for orchestrating AI coding agents in isolated sandboxes.
- You call
sanddune.run(). - An agent runs in a Docker container against a git worktree.
- You get commits on a branch.
- Your host working tree stays clean (or doesn't, depending on the branch strategy you pick).
Note: sanddune is heavily inspired by Matt Pocock's sandcastle. The core orchestration model — branch strategies, bind-mount providers, the
run()API shape — is based on his work.
- Git
- Docker Desktop (or any Docker-compatible runtime)
- Bun ≥ 1.3 if you're working in this repo
- An
ANTHROPIC_API_KEY
| Surface | Status | Notes |
|---|---|---|
run() |
✅ shipped | Single iteration, bind-mount only, inline prompt only |
docker() sandbox provider |
✅ shipped | Bind-mount, auto image-exists check, parent .git re-mount for worktrees |
claudeCode() agent provider |
✅ shipped | --print --output-format stream-json --verbose --dangerously-skip-permissions |
branchStrategy: { type: "head" } |
✅ shipped | Default. Agent writes directly to host working tree. |
branchStrategy: { type: "merge-to-head" } |
✅ shipped | Worktree under .sanddune/worktrees/<id>/, fast-forward back to HEAD on success |
branchStrategy: { type: "branch", branch } |
✅ shipped | Named branch in a worktree; reused on re-run |
| Env resolution | ✅ shipped | .sanddune/.env + agent/sandbox env + RunOptions.env |
| JSONL run log | ✅ shipped | Streamed to .sanddune/logs/<run-id>.jsonl |
createBindMountSandboxProvider() |
✅ shipped | Build your own bind-mount` |
There is no sanddune init yet — set up by hand. The repo's .sanddune/ directory shows the layout.
- Install:
npm install --save-dev @missingstudio/sanddune-
Create a
.sanddune/Dockerfilethat includesgit,gh, the Claude Code CLI, and a non-root user (use ./.sanddune/Dockerfile as a starting point). -
Build the image. The default tag is
sanddune:<repo-dir-name>:
docker build -t sanddune:my-repo -f .sanddune/Dockerfile .sanddune- Create
.sanddune/.envwith yourANTHROPIC_API_KEY:
ANTHROPIC_API_KEY=sk-ant-...
- Write a script and run it with
bun(ortsx):
import { run, claudeCode } from "@missingstudio/sanddune";
import { docker } from "@missingstudio/sanddune/sandboxes/docker";
const result = await run({
agent: claudeCode("claude-opus-4-7"),
sandbox: docker(),
prompt: "Add a HELLO.md file with the text 'hi'. Commit it.",
});
console.log(result.branch); // host's active branch
console.log(result.commits); // ["<sha>"]
console.log(result.logFilePath); // .sanddune/logs/<run-id>.jsonlThe agent runs once in Docker, makes a commit (or doesn't), and the container is destroyed.
A run goes through three phases:
- Setup — resolve env vars, determine the host's current branch, plan the branch strategy, create a worktree if needed, start the container with the worktree bind-mounted at
/workspace. - Agent invocation — invoke the agent with the (inline) prompt, stream JSON events into the run log, capture stdout text events.
- Teardown — read commits off the worktree HEAD, fast-forward back to the host branch (
merge-to-headonly), tear down the container, and clean up the worktree (preserving it on disk if dirty).
Today the iteration loop runs exactly once. maxIterations, completionSignal, idle/total timeouts, and abort signals are designed but not wired — see What's implemented today.
Configure where the agent's commits land via the branchStrategy option on run(). When omitted, defaults to { type: "head" } for bind-mount providers (the only kind that can run today).
| Strategy | Where commits land | Host HEAD touched? |
|---|---|---|
{ type: "head" } |
Host working tree directly. No worktree. | Yes |
{ type: "merge-to-head" } |
Temp branch in a worktree, fast-forwarded into HEAD | Yes (on success) |
{ type: "branch", branch: "agent/fix-42" } |
Named branch in a worktree | No |
// head — fast iteration during development
await run({
agent: claudeCode("claude-opus-4-7"),
sandbox: docker(),
prompt: "...",
});
// merge-to-head — safer; HEAD untouched if anything goes wrong mid-run
await run({
agent: claudeCode("claude-opus-4-7"),
sandbox: docker(),
branchStrategy: { type: "merge-to-head" },
prompt: "...",
});
// branch — commits on a named branch you can pick up later (e.g. for a PR)
await run({
agent: claudeCode("claude-opus-4-7"),
sandbox: docker(),
branchStrategy: { type: "branch", branch: "agent/fix-42" },
prompt: "...",
});For merge-to-head and branch, sanddune creates the worktree under .sanddune/worktrees/<id>/ with a lock under .sanddune/locks/<id>.lock. If the agent leaves the worktree dirty, it's preserved on disk and surfaced as result.worktreePath. For branch, re-running with the same branch name reuses the worktree (per ADR-0003).
import { run, claudeCode } from "@missingstudio/sanddune";
import { docker } from "@missingstudio/sanddune/sandboxes/docker";
const result = await run({
agent: claudeCode("claude-opus-4-7"),
sandbox: docker(),
prompt: "Fix the typo in README.md and commit.",
// Optional:
cwd: "../other-repo", // host repo root, defaults to process.cwd()
branchStrategy: { type: "branch", branch: "agent/x" }, // see Branch strategies
env: { MY_VAR: "value" }, // call-site env override (ADR-0012)
});| Option | Type | Behavior |
|---|---|---|
agent |
AgentProvider |
Required. Today: claudeCode(model, { env? }) |
sandbox |
BindMountSandboxProvider |
Required. Today: docker() or your own bind-mount provider |
prompt |
string |
Required. Inline prompt; passed to the agent as-is |
cwd |
string |
Host repo dir; relative paths resolve from process.cwd() |
branchStrategy |
BranchStrategy |
head (default) / merge-to-head / branch |
env |
Record<string, string> |
Call-site env override; highest precedence (ADR-0012) |
promptFile, promptArgs, maxIterations, completionSignal, hooks, timeouts, logging, signal, resumeSession, copyToWorktree. Passing promptFile throws; the rest are silently ignored. Don't depend on them.
interface RunResult {
branch: string; // result branch — host's active branch (head/merge-to-head) or named branch
worktreePath?: string; // set when the worktree was preserved on disk after a dirty close
iterations: IterationResult[]; // length === 1 today
commits: string[]; // SHAs reachable from worktree HEAD past the pre-run tip
completionSignal?: string; // never set today (signals not implemented)
stdout: string; // concatenated text events from the agent stream
logFilePath: string; // path to the JSONL run log
}
interface IterationResult {
iteration: number; // 1
commitSha?: string; // last commit on the worktree, if any
sessionFilePath?: string; // not set today (capture not implemented)
usage?: IterationUsage; // not set today
completionSignal?: string; // not set today
}Per ADR-0012, env vars are declaration-driven: a key reaches the sandbox iff it's declared in one of these layers. process.env only supplies values for already-declared keys.
Layers, lowest → highest precedence:
process.env— value source only, never declares.sanddune/.env— declaration site and value source- Agent provider
envand sandbox providerenv— declaration sites; must not overlap RunOptions.env— call-site escape hatch; declares, sets, and overrides
await run({
agent: claudeCode("claude-opus-4-7", {
env: { ANTHROPIC_API_KEY: "sk-ant-..." },
}),
sandbox: docker({
env: { SOME_DOCKER_VAR: "x" },
}),
env: { OVERRIDE: "y" },
prompt: "...",
});If an agent provider's env and a sandbox provider's env share a key, run() throws.
docker({
image: "sanddune:my-repo", // optional; defaults to sanddune:<repo-dir-name>
env: { SOMETHING: "value" }, // optional; merged per ADR-0012
})Today's DockerOptions is { image?, env? }. The image must already exist locally; sanddune does not build it for you (no sanddune init / build-image yet). Build manually:
docker build -t sanddune:my-repo -f .sanddune/Dockerfile .sandduneThe container is started with -w /workspace and the worktree bind-mounted there. If the worktree's .git is a pointer file (true for git worktrees), sanddune also bind-mounts the parent .git directory at its host path inside the container so the pointer resolves (per ADR-0006).
claudeCode("claude-opus-4-7", {
env: { ANTHROPIC_API_KEY: "sk-ant-..." }, // optional; merged per ADR-0012
})Today's ClaudeCodeOptions is { env? }. The effort and captureSessions options shown in the brief don't exist yet.
If you want to wrap something other than Docker (a different container runtime, an SSH host, a local subprocess), implement createBindMountSandboxProvider:
import {
createBindMountSandboxProvider,
type BindMountCreateOptions,
type BindMountSandboxHandle,
} from "@missingstudio/sanddune";
const myProvider = () =>
createBindMountSandboxProvider({
name: "my-provider",
create: async (opts: BindMountCreateOptions): Promise<BindMountSandboxHandle> => {
// opts.worktreePath — host path to mount into your sandbox
// opts.hostRepoPath — caller's `cwd`, e.g. for default image-name derivation
// opts.env — resolved env vars to inject
return {
worktreePath: "/workspace", // sandbox-side path
exec: async (command, execOpts) => ({
stdout: "...",
stderr: "...",
exitCode: 0,
}),
close: async () => { /* tear down */ },
};
},
});Reference implementation: packages/sanddune/src/sandboxes/docker.ts.
The isolated-provider factory is declared in the type system but not yet usable from run().
Every run streams JSONL events to .sanddune/logs/<run-id>.jsonl and prints a tail -f hint at start. Today the log is the only output channel — logging: { type: "stdout" } is accepted by the type but ignored. Follow it from another terminal:
tail -f .sanddune/logs/*.jsonl./.sanddune/ contains three runnable demos, one per branch strategy. After bun install && bun run build, build the image and run any of:
bun .sanddune/docker-head-claude-code.ts
bun .sanddune/docker-merge-to-head-claude-code.ts
bun .sanddune/docker-branch-claude-code.tsSee .sanddune/README.md for the full walkthrough.
CONTEXT.md— domain language and relationshipsdocs/adr/— architecture decision records
bun install
bun run build # tsc -p tsconfig.build.json + bun build, orchestrated by turbo
bun test # bun test
bun run typecheck # tsgo --noEmitsanddune is heavily inspired by sandcastle by Matt Pocock. If you're evaluating agent orchestration libraries, go check out the original.
MIT
