@chenshaohui6988/sandcastle
v0.13.2
Published
CLI for orchestrating AI agents in isolated sandbox environments
Maintainers
Readme
What Is Sandcastle?
A TypeScript library for orchestrating AI coding agents in isolated sandboxes:
- You invoke agents with a single
sandcastle.run(). - Sandcastle handles sandboxing the agent with a configurable branch strategy.
- 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
createBindMountSandboxProviderorcreateIsolatedSandboxProvider
Quick start
By the end of this path, you should know three things:
- how to scaffold a Sandcastle runner for a repository
- how the runner chooses the right agent skill flow before coding
- when to use single-repo
run(), PRD-drivenrunWorkspaceTask(), or lower-levelrunWorkspace()
- Install the package:
npm install --save-dev @chenshaohui6988/sandcastle- Run
npx @chenshaohui6988/sandcastle init. This scaffolds a.sandcastledirectory with all the files needed.
npx @chenshaohui6988/sandcastle init- Edit
.sandcastle/.envand fill in your default values forCLAUDE_CODE_OAUTH_TOKEN(runclaude setup-tokenon your host to get one). To use an Anthropic API key instead, uncomment and fill inANTHROPIC_API_KEY.
cp .sandcastle/.env.example .sandcastle/.env- Run the
.sandcastle/main.ts(ormain.mts) file withnpx tsx
npx tsx .sandcastle/main.ts// 3. Run the agent via the JS API
import { run, claudeCode } from "@chenshaohui6988/sandcastle";
import { docker } from "@chenshaohui6988/sandcastle/sandboxes/docker";
await run({
agent: claudeCode("claude-opus-4-8"),
sandbox: docker(), // or podman(), vercel(), or your own provider
promptFile: ".sandcastle/prompt.md",
});Quick Smoke Test
Before you hand Sandcastle a real task, run a minimal read-only check to confirm the whole pipeline is wired up: the sandbox starts, the agent authenticates, a command runs inside it, and the iteration loop exits cleanly. It is the fastest way to separate a setup problem from a task problem.
Drop this into .sandcastle/main.ts (or any scratch file) and run it with npx tsx:
import { run, claudeCode } from "@chenshaohui6988/sandcastle";
import { docker } from "@chenshaohui6988/sandcastle/sandboxes/docker";
const result = await run({
agent: claudeCode("claude-opus-4-8"),
sandbox: docker(), // or podman(), noSandbox()
prompt:
"Run `echo sandcastle-ok` and report what it printed. " +
"Do not modify any files. " +
"Output <promise>COMPLETE</promise> when done.",
});
console.log(result.completionSignal); // "<promise>COMPLETE</promise>"
console.log(result.commits.length); // 0 — the smoke test changes nothingnpx tsx .sandcastle/main.tsA healthy run typically finishes within a minute and:
- prints the completion signal (
<promise>COMPLETE</promise>) — the agent started, authenticated, and the loop exited on the signal instead of timing out - reports
0commits — the read-only prompt left your working tree untouched
If it hangs or throws instead, you have isolated the failure to setup rather than your task. Common causes:
- Auth —
CLAUDE_CODE_OAUTH_TOKEN(orANTHROPIC_API_KEY) missing or invalid in.sandcastle/.env - Sandbox — Docker/Podman is not running, or the image is not built yet (
sandcastle docker build-image) - Network — the agent CLI cannot reach its model provider from inside the sandbox
Best practice: keep this check around and re-run it after editing your Dockerfile, rotating tokens, or upgrading Sandcastle. A green smoke test confirms the plumbing before you debug a real run.
Agent Skill Routing
Sandcastle scaffolds .sandcastle/SKILL_ROUTER.md as a companion workflow guide. Before an agent starts project work, it should read the router, choose the matching skill flow, and make sure the target repository's AGENTS.md or CLAUDE.md accurately describes the project-specific rules.
Think of the router as a table of contents for agent work. It does not install every skill, and it does not replace project guidance. It helps the agent answer one question first: "What kind of work is this?"
Codex and Claude Code can load skills differently, but the Sandcastle habit is the same: choose first, load second. Codex should keep the selected skills active in its skill profile. Claude Code should have the matching SKILL.md directories available under .claude/skills/ so its native skill discovery can read only the skills needed for the task.
This is the recommended entry sequence:
- Run
sandcastle init. - Review
.sandcastle/SKILL_ROUTER.md. - Sync the selected skills into the active agent setup.
- Update
AGENTS.mdorCLAUDE.mdwhen project guidance is missing or stale. - Start implementation only after the workflow is selected.
See sandcastle init for the full business flow diagram and best practices.
Learn The Workflow
Sandcastle is easiest to learn as a sequence, not as an API catalog.
1. Start With The Smallest Runner
Use sandcastle init first. It creates the local runner files under .sandcastle/ and gives the agent a prompt file to read. Do not start by writing a custom orchestration script unless you already know which lifecycle you need.
After init, inspect these files:
| File | What to learn from it |
| ----------------------------- | ------------------------------------------------------------------------ |
| .sandcastle/main.ts | Which agent, sandbox provider, branch strategy, and prompt file will run |
| .sandcastle/prompt.md | What the agent is asked to do each iteration |
| .sandcastle/SKILL_ROUTER.md | Which skill flow should be selected before project work starts |
| .sandcastle/workspace.json | Which repositories are candidates for multi-repository planning |
| .sandcastle/.env.example | Which host-side credentials the selected agent and issue tracker need |
2. Pick The Right Execution Shape
Choose the smallest API that matches the work:
| Situation | Use this | Why |
| ---------------------------------------------- | ------------------------ | --------------------------------------------------------------- |
| One prompt, one repository | run() | Starts an agent once or for a bounded number of iterations |
| Several agent passes on the same branch | createSandbox() | Reuses one sandbox for implement-then-review or repair loops |
| You need a managed branch before running agent | createWorktree() | Gives you direct control over worktree lifecycle |
| One request may touch several repositories | runWorkspaceTask() | Plans alignment, technical work, repo issues, and execution |
| One agent must see several repos at once | runWorkspace() | Lower-level multi-repo sandbox primitive |
| A local markdown issue should be implemented | sandcastle local-issue | Runs a scoped host-side issue flow without per-repo scaffolding |
3. Do A First Practice Run
For a safe first pass, use a read-only prompt:
# Task
Read this repository's AGENTS.md, CLAUDE.md, and .sandcastle/SKILL_ROUTER.md.
Explain which skill flow should be used for a small bug fix.
# Done
Output <promise>COMPLETE</promise> when finished.Run it with:
npx tsx .sandcastle/main.tsYou should see the agent explain the selected flow without changing code. A good first result names the likely flow, says whether AGENTS.md or CLAUDE.md needs an update, and stops before implementation. After that, replace the prompt with a real issue or use the workspace commands for PRD-driven work.
4. Avoid Common Mistakes
- Do not copy every available skill into the active agent. Load the skill flow selected by
.sandcastle/SKILL_ROUTER.md. - Do not put project facts in the router. Put build commands, repo boundaries, terminology, and verification rules in
AGENTS.mdorCLAUDE.md. - Do not start with
runWorkspaceTask()for a one-repo bug fix. Userun()until the task actually needs multi-repo planning. - Do not let chat history be the only source of instructions. If future agents need the rule, write it into project guidance.
- Do not skip the read-only practice run when teaching a new team or a new repository. It confirms the agent can find the router and explain the workflow before it writes code.
Check Your Understanding
Before you let an agent write code, you should be able to answer:
- Which file tells the agent how to choose a skill flow?
- Which file holds project-specific rules?
- Which API creates reusable sandboxes?
- Which command turns a PRD into repository-specific issues?
- Where will generated workspace planning artifacts be written?
If any answer is unclear, read sandcastle init, runWorkspaceTask(), and sandcastle workspace plan before running implementation.
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:
| Provider | Import path | Type | Accepted by |
| ---------- | -------------------------------------------------- | ---------- | ------------------------------------------------------------- |
| Docker | @chenshaohui6988/sandcastle/sandboxes/docker | Bind-mount | run(), runWorkspace(), createSandbox(), interactive() |
| Podman | @chenshaohui6988/sandcastle/sandboxes/podman | Bind-mount | run(), runWorkspace(), createSandbox(), interactive() |
| Vercel | @chenshaohui6988/sandcastle/sandboxes/vercel | Isolated | run(), createSandbox(), interactive() |
| No-sandbox | @chenshaohui6988/sandcastle/sandboxes/no-sandbox | None | run(), 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 "@chenshaohui6988/sandcastle/sandboxes/docker";
import { podman } from "@chenshaohui6988/sandcastle/sandboxes/podman";
import { vercel } from "@chenshaohui6988/sandcastle/sandboxes/vercel";
import { noSandbox } from "@chenshaohui6988/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 programmatic APIs for use in scripts, CI pipelines, or custom tooling. Use run() for one repository, runWorkspaceTask() when one product request may affect multiple repositories, and runWorkspace() when you need the lower-level multi-repository sandbox primitive directly. The examples below use docker(), but any compatible SandboxProvider works in its place.
import { run, claudeCode } from "@chenshaohui6988/sandcastle";
import { docker } from "@chenshaohui6988/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 namerunWorkspaceTask() — plan and execute across repositories
Use runWorkspaceTask() when you have a PRD or product request and a set of candidate repositories. Sandcastle runs a planner agent first, asks it to produce PRD alignment notes, a technical plan, and repository-local issues, then runs one executor agent per selected repository in parallel. Each executor gets its own managed worktree, branch, commits, dirty preservation, and result entry.
import { runWorkspaceTask, claudeCode } from "@chenshaohui6988/sandcastle";
import { docker } from "@chenshaohui6988/sandcastle/sandboxes/docker";
const result = await runWorkspaceTask({
repositories: [
{
name: "vocimcore",
cwd: "/Users/me/IdeaProjects/vocimcore",
kind: "backend",
description: "Core domain model, API contract, and shared types",
},
{
name: "vocsearchmng",
cwd: "/Users/me/IdeaProjects/vocsearchmng",
kind: "backend",
description: "Search service integration",
},
{
name: "vocimmng",
cwd: "/Users/me/IdeaProjects/vocimmng",
kind: "backend",
description: "IM management backend",
},
{
name: "vocmngweb",
cwd: "/Users/me/IdeaProjects/vocmngweb",
kind: "frontend",
description: "Management UI",
},
{
name: "vocprod",
cwd: "/Users/me/IdeaProjects/vocprod",
kind: "frontend",
description: "Product-facing UI",
},
],
agent: claudeCode("claude-opus-4-8"),
sandbox: docker(),
branchPrefix: "codex/01-add-im-session-robot-source",
prompt: "Implement IM session robot source support.",
});
console.log(result.plan.alignment); // automatic PRD alignment notes
console.log(result.plan.technicalPlan); // cross-repository technical plan
console.log(result.plan.repositories); // repository issues selected by the planner
console.log(result.repositories.vocimcore?.commits);
console.log(result.repositories.vocmngweb?.status);The planner must emit a <workspace_plan> JSON block containing alignment, a technicalPlan, and per-repository issue bodies. Sandcastle validates that every planned repository exists in the candidate list and rejects duplicate or unknown repository names before execution.
Execution result entries are grouped by repository:
type WorkspaceTaskRepositoryResult = {
task: string;
reason?: string;
status: "success" | "failed";
branch: string;
commits: Array<{ sha: string }>;
stdout?: string;
preservedWorktreePath?: string;
error?: string;
};You can run the same flow from the CLI with a JSON config instead of writing a TypeScript runner:
{
"branchPrefix": "codex/01-add-im-session-robot-source",
"repositories": [
{
"name": "vocimcore",
"cwd": "../vocimcore",
"kind": "backend",
"description": "Core domain model, API contract, and shared types"
},
{
"name": "vocmngweb",
"cwd": "../vocmngweb",
"kind": "frontend",
"description": "Management UI"
}
]
}sandcastle workspace plan --prd-file ./prd.md
# Review .scratch/<prd-name>/alignment.md, technical-plan.md, and issues/*.md
sandcastle workspace execute --plan-file .scratch/<prd-name>/workspace-plan.jsonFor PRD-first workflows, workspace plan --prd-file ./prd.md writes .scratch/<prd-name>/workspace-plan.json, .scratch/<prd-name>/alignment.md, .scratch/<prd-name>/technical-plan.md, and .scratch/<prd-name>/issues/<repo>.md. The plan JSON snapshots the workspace chosen for that PRD, so later execution uses the repositories recorded in the plan instead of whatever .sandcastle/workspace.json contains at that time. Review those artifacts, then run workspace execute --plan-file .scratch/<prd-name>/workspace-plan.json to execute the approved repository issues in parallel.
workspace run --prd-file ./prd.md is the fully automatic pipeline: PRD alignment, technical plan, repository issue generation, and execution in one command. When the current repo has exactly one ready local issue under .scratch/, workspace run can still use that issue as the prompt file automatically. Pass one of --prd, --prd-file, --prompt, or --prompt-file to override it.
runWorkspace() — multi-repository tasks
Use runWorkspace() when you need the lower-level primitive: one agent invocation sees several managed repositories in the same sandbox. Sandcastle creates one managed worktree per repository, bind-mounts all of them into the same sandbox under /home/agent/repos/<name>, runs the agent from the primary repository, and returns commits grouped by repository.
import { runWorkspace, claudeCode } from "@chenshaohui6988/sandcastle";
import { docker } from "@chenshaohui6988/sandcastle/sandboxes/docker";
const result = await runWorkspace({
repositories: [
{
name: "vocimcore",
cwd: "/Users/me/IdeaProjects/vocimcore",
branchStrategy: {
type: "branch",
branch: "codex/01-add-im-session-robot-source-core",
},
copyToWorktree: ["node_modules"],
},
{
name: "vocmngweb",
cwd: "/Users/me/IdeaProjects/vocmngweb",
branchStrategy: {
type: "branch",
branch: "codex/01-add-im-session-robot-source-web",
},
},
],
primaryRepository: "vocimcore",
agent: claudeCode("claude-opus-4-8"),
sandbox: docker(),
prompt: "Implement the cross-repo feature.",
maxIterations: 1,
});
console.log(result.repositories.vocimcore.commits);
console.log(result.repositories.vocmngweb.commits);The agent prompt is automatically appended with a workspace manifest listing every repository name, sandbox path, branch, and the primary repository. For example, vocimcore is available at /home/agent/repos/vocimcore and vocmngweb at /home/agent/repos/vocmngweb.
Each repository has its own cwd, branchStrategy, copyToWorktree, and hooks. If branchStrategy is omitted, Sandcastle uses { type: "merge-to-head" } for that repository. { type: "branch", branch } is supported. { type: "head" } is rejected because runWorkspace() always uses managed worktrees.
The result is grouped by repository:
type RunWorkspaceResult = {
repositories: {
[name: string]: {
branch: string;
worktreePath: string;
commits: Array<{ sha: string }>;
preservedWorktreePath?: string;
};
};
stdout: string;
iterations: IterationResult[];
completionSignal?: string;
logFilePath?: string;
};V1 limitations:
runWorkspace()supports bind-mount sandbox providers only, such as Docker and Podman. Isolated providers throw a clear error.- The lower-level
runWorkspace()primitive has no direct CLI. Usesandcastle workspace runfor the product-level planner/executor workflow. - Extra provider mounts are still provider-level mounts. Repositories that need git lifecycle management must be listed in
repositories.
All options
import { run, claudeCode } from "@chenshaohui6988/sandcastle";
import { docker } from "@chenshaohui6988/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: "../other-repo",
// Branch strategy — controls how the agent's changes relate to branches.
// Defaults to { type: "head" } for bind-mount and { type: "merge-to-head" } for isolated providers.
branchStrategy: { type: "branch", branch: "agent/fix-42" },
// Prompt source — provide one of these, not both.
// Note: promptFile resolves against process.cwd(), NOT cwd.
promptFile: ".sandcastle/prompt.md", // path to a prompt file
// prompt: "Fix issue #42 in this repo", // OR an inline prompt string
// Values substituted for {{KEY}} placeholders in the prompt.
promptArgs: {
ISSUE_NUMBER: "42",
},
// Maximum number of agent iterations to run before stopping. Default: 1
maxIterations: 5,
// Display name for this run, shown as a prefix in log output.
name: "fix-issue-42",
// Lifecycle hooks grouped by where they run: host or sandbox.
hooks: {
host: {
onWorktreeReady: [{ command: "cp .env.example .env" }],
onSandboxReady: [{ command: "echo setup done" }],
},
sandbox: {
onSandboxReady: [{ command: "npm install" }],
},
},
// Host-relative file paths to copy into the sandbox before the container starts.
// Not supported with branchStrategy: { type: "head" }.
copyToWorktree: [".env"],
// Override default timeouts for built-in lifecycle steps.
// Unset keys keep their defaults.
timeouts: {
copyToWorktreeMs: 120_000, // default: 60_000
gitSetupMs: 30_000, // default: 10_000
commitCollectionMs: 60_000, // default: 30_000
mergeToHostMs: 60_000, // default: 30_000
},
// How to record progress. Default: write to a file under .sandcastle/logs/
logging: {
type: "file",
path: ".sandcastle/logs/my-run.log",
// Optional: forward the agent's output stream to your own observability system.
// Fires for each text chunk, tool call, and raw stdout line the agent
// produces. Errors thrown by the callback are swallowed so a broken
// forwarder cannot kill the run.
onAgentStreamEvent: (event) => {
// event is { type: "text" | "toolCall" | "raw", iteration, timestamp, ... }
myLogger.info(event);
},
// Optional: append every raw stdout line the agent emits to the same
// log file, interleaved with the human-readable output. Includes lines
// the provider's stream parser would otherwise drop. Intended for
// debugging stuck or unexpected agent behaviour.
verbose: true,
},
// logging: { type: "stdout", verbose: true }, // OR terminal mode (verbose: raw lines to stdout)
// String (or array of strings) the agent emits to end the iteration loop early.
// Default: "<promise>COMPLETE</promise>"
completionSignal: "<promise>COMPLETE</promise>",
// Idle timeout in seconds — resets whenever the agent produces output. Default: 600 (10 minutes)
idleTimeoutSeconds: 600,
// Grace window in seconds after the agent emits a completion signal but
// before its process has exited (a "hanging process" — typically a spawned
// `gh`/git child or MCP server keeping stdout open). Resets on every
// subsequent output line so trailing data is still captured. Default: 60
completionTimeoutSeconds: 60,
// Structured output — extract a typed payload from the agent's stdout.
// Requires maxIterations === 1 and the tag must appear in the prompt.
// output: Output.object({ tag: "result", schema: z.object({ answer: z.number() }) }),
// output: Output.string({ tag: "summary" }),
});
console.log(result.iterations.length); // number of iterations executed
console.log(result.completionSignal); // matched signal string, or undefined if none fired
console.log(result.commits); // array of { sha } for commits created
console.log(result.branch); // target branch namecreateSandbox() — reusable sandbox
Use createSandbox() when you need to run multiple agents (or multiple rounds of the same agent) inside a single sandbox. It creates the sandbox once, and you call sandbox.run() as many times as you need. This avoids repeated container startup costs and keeps all runs on the same branch.
Use run() instead when you only need a single one-shot invocation — it handles sandbox lifecycle automatically.
Basic single-run usage
import { createSandbox, claudeCode } from "@chenshaohui6988/sandcastle";
import { docker } from "@chenshaohui6988/sandcastle/sandboxes/docker";
await using sandbox = await createSandbox({
branch: "agent/fix-42",
sandbox: docker(),
});
const result = await sandbox.run({
agent: claudeCode("claude-opus-4-8"),
prompt: "Fix issue #42 in this repo.",
});
console.log(result.commits); // [{ sha: "abc123" }]Multi-run implement-then-review
import { createSandbox, claudeCode } from "@chenshaohui6988/sandcastle";
import { docker } from "@chenshaohui6988/sandcastle/sandboxes/docker";
await using sandbox = await createSandbox({
branch: "agent/fix-42",
sandbox: docker(),
hooks: { sandbox: { onSandboxReady: [{ command: "npm install" }] } },
});
// Step 1: implement
const implResult = await sandbox.run({
agent: claudeCode("claude-opus-4-8"),
promptFile: ".sandcastle/implement.md",
maxIterations: 5,
});
// Step 2: review on the same branch, same container
const reviewResult = await sandbox.run({
agent: claudeCode("claude-sonnet-4-6"),
prompt: "Review the changes and fix any issues.",
});Commits from all run() calls accumulate on the same branch. The sandbox container stays alive between runs, so installed dependencies and build artifacts persist.
sandbox.exec() lets the harness run shell commands directly in the same warm sandbox — handy for gating an implement step on a quick verification before kicking off the review:
await using sandbox = await createSandbox({
branch: "agent/fix-42",
sandbox: docker(),
hooks: { sandbox: { onSandboxReady: [{ command: "npm install" }] } },
});
await sandbox.run({
agent: claudeCode("claude-opus-4-8"),
promptFile: ".sandcastle/implement.md",
maxIterations: 5,
});
// Verify before review — non-zero exitCode is returned, not thrown.
const tests = await sandbox.exec("npm test");
if (tests.exitCode !== 0) {
throw new Error(`Tests failed:\n${tests.stdout}\n${tests.stderr}`);
}
await sandbox.run({
agent: claudeCode("claude-sonnet-4-6"),
prompt: "Review the changes and fix any issues.",
});cwd defaults to the sandbox repo path, matching interactive(). Pass cwd to override.
Automatic cleanup with await using
await using calls sandbox.close() automatically when the block exits. If the sandbox has uncommitted changes, the worktree is preserved on disk; if clean, both container and worktree are removed.
Manual close() with CloseResult
const sandbox = await createSandbox({
branch: "agent/fix-42",
sandbox: docker(),
});
// ... run agents ...
const closeResult = await sandbox.close();
if (closeResult.preservedWorktreePath) {
console.log(`Worktree preserved at ${closeResult.preservedWorktreePath}`);
}CreateSandboxOptions
| Option | Type | Default | Description |
| ---------------- | --------------- | --------------- | ------------------------------------------------------------------------------------------------------------------- |
| branch | string | — | Required. Explicit branch for the sandbox |
| sandbox | SandboxProvider | — | Required. Sandbox provider (e.g. docker(), podman()) |
| cwd | string | process.cwd() | Host repo directory — relative paths resolve against process.cwd() |
| hooks | SandboxHooks | — | Lifecycle hooks (host.*, sandbox.*) — run once at creation time |
| copyToWorktree | string[] | — | Host-relative file paths to copy into the sandbox at creation time |
| timeouts | Timeouts | — | Override built-in lifecycle step timeouts (copyToWorktreeMs, gitSetupMs, commitCollectionMs, mergeToHostMs) |
Sandbox
| Property / Method | Type | Description |
| ----------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- |
| branch | string | The branch the sandbox is on |
| worktreePath | string | Host path to the worktree |
| run(options) | (SandboxRunOptions) => Promise<SandboxRunResult> | Invoke an agent inside the existing sandbox |
| interactive(options) | (SandboxInteractiveOptions) => Promise<SandboxInteractiveResult> | Launch an interactive session in the sandbox |
| exec(cmd, options?) | (command: string, options?: SandboxExecOptions) => Promise<ExecResult> | Run a shell command in the sandbox. cwd defaults to the sandbox repo path. Non-zero exitCode is returned, not thrown. |
| close() | () => Promise<CloseResult> | Tear down the container and sandbox |
| [Symbol.asyncDispose] | () => Promise<void> | Auto teardown via await using |
SandboxRunOptions
| Option | Type | Default | Description |
| -------------------------- | ------------------ | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| agent | AgentProvider | — | Required. Agent provider (e.g. claudeCode("claude-opus-4-8")) |
| prompt | string | — | Inline prompt (mutually exclusive with promptFile) |
| promptFile | string | — | Path to prompt file (mutually exclusive with prompt) |
| promptArgs | PromptArgs | — | Key-value map for {{KEY}} placeholder substitution |
| maxIterations | number | 1 | Maximum iterations to run |
| completionSignal | string | string[] | <promise>COMPLETE</promise> | String(s) the agent emits to stop the iteration loop early |
| idleTimeoutSeconds | number | 600 | Idle timeout in seconds — resets on each agent output event |
| completionTimeoutSeconds | number | 60 | Grace window after the completion signal is seen but the agent process hasn't exited |
| name | string | — | Display name for the run |
| logging | object | file (auto-generated) | { type: 'file', path } or { type: 'stdout' } |
| resumeSession | string | — | Resume a prior session by ID for agents that support resume. Incompatible with maxIterations > 1. Session file must exist on host. |
| signal | AbortSignal | — | Cancels the run when aborted; handle stays usable afterward |
SandboxRunResult
| Field | Type | Description |
| -------------------------- | ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| iterations | IterationResult[] | Per-iteration results (use .length for the count) |
| completionSignal | string? | The matched completion signal string, or undefined if none fired |
| stdout | string | Combined agent output from all iterations |
| commits | { sha }[] | Commits created during the run |
| logFilePath | string? | Path to the log file (only when logging to a file) |
| resume(prompt, options?) | (prompt: string, options?: ResumeSandboxRunResultOptions) => Promise<SandboxRunResult> | Continue the captured session for one iteration inside the same warm sandbox. Present only when the provider captured a session id. |
| fork(prompt, options?) | (prompt: string, options?: ResumeSandboxRunResultOptions) => Promise<SandboxRunResult> | Fork the captured session for one iteration inside the same warm sandbox. The parent session is left intact (ADR 0018). |
CloseResult
| Field | Type | Description |
| ----------------------- | ------- | ------------------------------------------------------------------------ |
| preservedWorktreePath | string? | Host path to the preserved worktree, set when it had uncommitted changes |
createWorktree() — independent worktree lifecycle
Use createWorktree() when you need a worktree (git worktree) as an independent, first-class concept — separate from any sandbox. This is useful when you want to run an interactive session first and then hand the same worktree to a sandboxed AFK agent.
Only branch and merge-to-head strategies are accepted; head is a compile-time type error since it means no worktree.
Pass cwd to target a repo other than process.cwd(). Relative paths resolve against process.cwd(); absolute paths pass through. A CwdError is thrown if the path does not exist or is not a directory.
import { createWorktree } from "@chenshaohui6988/sandcastle";
await using wt = await createWorktree({
branchStrategy: { type: "branch", branch: "agent/fix-42" },
copyToWorktree: ["node_modules"],
cwd: "/path/to/other-repo", // optional — defaults to process.cwd()
});
console.log(wt.worktreePath); // host path to the worktree
console.log(wt.branch); // "agent/fix-42"
// Run an interactive session in the worktree (defaults to noSandbox)
await wt.interactive({
agent: claudeCode("claude-opus-4-8"),
prompt: "Explore the codebase and understand the bug.",
});
// Run an AFK agent in the worktree (sandbox is required)
const result = await wt.run({
agent: claudeCode("claude-opus-4-8"),
sandbox: docker({ imageName: "sandcastle:myrepo" }),
prompt: "Fix issue #42.",
maxIterations: 3,
});
console.log(result.commits); // commits made during the run
// Create a long-lived sandbox from the worktree
import { docker } from "@chenshaohui6988/sandcastle/sandboxes/docker";
await using sandbox = await wt.createSandbox({
sandbox: docker(),
hooks: { sandbox: { onSandboxReady: [{ command: "npm install" }] } },
});
// sandbox.close() tears down the container only — the worktree stays
await sandbox.close();
// wt.close() cleans up the worktreewt.close() checks for uncommitted changes: if the worktree is dirty, it's preserved on disk; if clean, it's removed. await using calls close() automatically. The worktree persists after run(), interactive(), and createSandbox() complete, so you can hand it to another agent or inspect it.
With branchStrategy: { type: "merge-to-head" }, each wt.run() / wt.interactive() merges the agent's commits back to the host's current branch before returning, and the worktree's source branch is preserved across calls so subsequent ones can reuse the same handle. (This differs from top-level run(), where the temp branch is deleted after the merge.)
Split ownership: When a sandbox is created via wt.createSandbox(), sandbox.close() tears down the container only — the worktree remains. wt.close() is responsible for worktree cleanup. This differs from the top-level createSandbox(), where sandbox.close() owns both container and worktree.
CreateWorktreeOptions
| Option | Type | Default | Description |
| ---------------- | ---------------------- | ------- | ------------------------------------------------------------------------------------------------------------------- |
| branchStrategy | WorktreeBranchStrategy | — | Required. { type: "branch", branch } or { type: "merge-to-head" } |
| copyToWorktree | string[] | — | Host-relative file paths to copy into the worktree at creation time |
| timeouts | Timeouts | — | Override built-in lifecycle step timeouts (copyToWorktreeMs, gitSetupMs, commitCollectionMs, mergeToHostMs) |
Worktree
| Property / Method | Type | Description |
| ------------------------ | --------------------------------------------------------------------- | --------------------------------------------------- |
| branch | string | The branch the worktree is on |
| worktreePath | string | Host path to the worktree |
| run(options) | (options: WorktreeRunOptions) => Promise<WorktreeRunResult> | Run an AFK agent in the worktree (sandbox required) |
| interactive(options) | (options: WorktreeInteractiveOptions) => Promise<InteractiveResult> | Run an interactive agent session in the worktree |
| createSandbox(options) | (options: WorktreeCreateSandboxOptions) => Promise<Sandbox> | Create a long-lived sandbox backed by this worktree |
| close() | () => Promise<CloseResult> | Clean up the worktree (preserves if dirty) |
| [Symbol.asyncDispose] | () => Promise<void> | Auto cleanup via await using |
WorktreeInteractiveOptions
| Option | Type | Default | Description |
| ------------ | ---------------------- | ------------- | ------------------------------------------------------------------------------------------------- |
| agent | AgentProvider | — | Required. Agent provider |
| sandbox | AnySandboxProvider | noSandbox() | Sandbox provider (defaults to no sandbox) |
| prompt | string | — | Inline prompt (mutually exclusive with promptFile) |
| promptFile | string | — | Path to prompt file |
| name | string | — | Optional session name |
| hooks | SandboxHooks | — | Lifecycle hooks (host.*, sandbox.*) |
| promptArgs | PromptArgs | — | Key-value map for {{KEY}} placeholder substitution |
| env | Record<string, string> | — | Environment variables to inject into the sandbox |
| signal | AbortSignal | — | Cancel the session when aborted. The worktree is preserved on disk. Rejects with signal.reason. |
WorktreeRunOptions
| Option | Type | Default | Description |
| -------------------------- | ---------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| agent | AgentProvider | — | Required. Agent provider |
| sandbox | SandboxProvider | — | Required. Sandbox provider (AFK agents must be sandboxed) |
| prompt | string | — | Inline prompt (mutually exclusive with promptFile) |
| promptFile | string | — | Path to prompt file |
| maxIterations | number | 1 | Maximum iterations to run |
| completionSignal | string | string[] | — | Substring(s) to stop the iteration loop early |
| idleTimeoutSeconds | number | 600 | Idle timeout in seconds |
| completionTimeoutSeconds | number | 60 | Grace window after completion signal is seen but agent process hasn't exited |
| name | string | — | Optional run name |
| logging | LoggingOption | file | Logging mode |
| hooks | SandboxHooks | — | Lifecycle hooks (host.*, sandbox.*) |
| promptArgs | PromptArgs | — | Key-value map for {{KEY}} placeholder substitution |
| env | Record<string, string> | — | Environment variables to inject into the sandbox |
| resumeSession | string | — | Resume a prior session by ID for agents that support resume. Incompatible with maxIterations > 1. Session file must exist on host. |
| signal | AbortSignal | — | Cancel the run when aborted. Kills the in-flight agent subprocess; the worktree is preserved on disk. Rejects with signal.reason. |
WorktreeRunResult
| Property | Type | Description |
| ------------------ | ------------------- | ------------------------------------------------------ |
| iterations | IterationResult[] | Per-iteration results (use .length for the count) |
| completionSignal | string | The matched completion signal, or undefined |
| stdout | string | Combined stdout output from all agent iterations |
| commits | { sha: string }[] | List of commits made by the agent during the run |
| branch | string | The branch name the agent worked on |
| logFilePath | string | Path to the log file, if logging was drained to a file |
WorktreeCreateSandboxOptions
| Option | Type | Default | Description |
| ---------------- | --------------- | ------- | ------------------------------------------------------------------------------------------------------------------- |
| sandbox | SandboxProvider | — | Required. Sandbox provider (e.g. docker()) |
| hooks | SandboxHooks | — | Lifecycle hooks (host.*, sandbox.*) |
| copyToWorktree | string[] | — | Host-relative file paths to copy into the worktree at creation time |
| timeouts | Timeouts | — | Override built-in lifecycle step timeouts (copyToWorktreeMs, gitSetupMs, commitCollectionMs, mergeToHostMs) |
How it works
Sandcastle uses a branch strategy configured on the sandbox provider to control how the agent's changes relate to branches. There are three strategies:
- Head (
{ type: "head" }) — The agent writes directly to the host working directory. No worktree, no branch indirection. This is the default for bind-mount providers likedocker(). - Merge-to-head (
{ type: "merge-to-head" }) — Sandcastle creates a temporary branch in a git worktree. The agent works on the temp branch, and changes are merged back to HEAD when done. The temp branch is cleaned up after merge. - Branch (
{ type: "branch", branch: "foo" }) — Commits land on an explicitly named branch in a git worktree. Re-running with the same branch reuses the existing worktree and fast-forwards it fromoriginwhen safe — see ADR 0003.
For bind-mount providers (like Docker), the worktree directory is bind-mounted into the container — the agent writes directly to the host filesystem through the mount, so no sync is needed.
From your point of view, you just configure branchStrategy: { type: 'branch', branch: 'foo' } on run(), and get a commit on branch foo once it's complete. All 100% local.
Prompts
Sandcastle uses a flexible prompt system. You write the prompt, and the engine executes it — no opinions about workflow, task management, or context sources are imposed.
Prompt resolution
You must provide exactly one of:
prompt: "inline string"— pass an inline prompt directly viaRunOptionspromptFile: "./path/to/prompt.md"— point to a specific file viaRunOptions
prompt and promptFile are mutually exclusive — providing both is an error. If neither is provided, run() throws an error asking you to supply one.
Inline prompts (prompt: "...") are passed to the agent literally. No {{KEY}} substitution, no !`command` expansion, no built-in {{SOURCE_BRANCH}} / {{TARGET_BRANCH}} injection. If you need values interpolated into an inline prompt, build the string in JavaScript (`Work on ${branch}…`). Passing promptArgs alongside an inline prompt is an error — switch to promptFile to use substitution.
The substitution and expansion features below apply only to prompts sourced from promptFile.
Convention:
sandcastle initscaffolds.sandcastle/prompt.mdand all templates explicitly reference it viapromptFile: ".sandcastle/prompt.md". This is a convention, not an automatic fallback — Sandcastle does not read.sandcastle/prompt.mdunless you pass it aspromptFile.
Dynamic context with !`command`
Use !`command` expressions in your prompt to pull in dynamic context. Each expression is replaced with the command's stdout before the prompt is sent to the agent. All expressions in a prompt run in parallel for faster expansion.
Commands run inside the sandbox after sandbox.onSandboxReady hooks complete, so they see the same repo state the agent sees (including installed dependencies).
# Open issues
!`gh issue list --state open --label Sandcastle --json number,title,body,comments,labels --limit 100`
# Recent commits
!`git log --oneline -10`If any command exits with a non-zero code, the run fails immediately with an error.
Prompt arguments with {{KEY}}
Use {{KEY}} placeholders in your prompt to inject values from the promptArgs option. This is useful for reusing the same prompt file across multiple runs with different parameters.
import { run } from "@chenshaohui6988/sandcastle";
await run({
promptFile: "./my-prompt.md",
promptArgs: { ISSUE_NUMBER: 42, PRIORITY: "high" },
});In the prompt file:
Work on issue #{{ISSUE_NUMBER}} (priority: {{PRIORITY}}).Prompt argument substitution runs on the host before shell expression expansion, so {{KEY}} placeholders inside !`command` expressions are replaced first:
!`gh issue view {{ISSUE_NUMBER}} --json body -q .body`A {{KEY}} placeholder with no matching prompt argument is an error. Unused prompt arguments produce a warning.
!`command` expansion only runs on shell blocks written in the prompt file itself. Any !`…` pattern that appears inside an argument value is treated as inert text — it won't be executed against the host shell. This makes it safe to pass user-authored content (issue titles, PR descriptions, docs excerpts) through promptArgs.
Built-in prompt arguments
Sandcastle automatically injects two built-in prompt arguments into every prompt:
| Placeholder | Value |
| ------------------- | ----------------------------------------------------------------- |
| {{SOURCE_BRANCH}} | The branch the agent works on (determined by the branch strategy) |
| {{TARGET_BRANCH}} | The host's active branch at run() time |
Use them in your prompt without passing them via promptArgs:
You are working on {{SOURCE_BRANCH}}. When diffing, compare against {{TARGET_BRANCH}}.Passing SOURCE_BRANCH or TARGET_BRANCH in promptArgs is an error — built-in prompt arguments cannot be overridden.
Early termination with <promise>COMPLETE</promise>
When the agent outputs <promise>COMPLETE</promise>, the orchestrator stops the iteration loop early. This is a convention you document in your prompt for the agent to follow — the engine never injects it.
This is useful for task-based workflows where the agent should stop once it has finished, rather than running all remaining iterations.
You can override the default signal by passing completionSignal to run(). It accepts a single string or an array of strings:
await run({
// ...
completionSignal: "DONE",
});
// Or pass multiple signals — the loop stops on the first match:
await run({
// ...
completionSignal: ["TASK_COMPLETE", "TASK_ABORTED"],
});Tell the agent to output your chosen string(s) in the prompt, and the orchestrator will stop when it detects any of them. The matched signal is returned as result.completionSignal.
Hanging processes after the completion signal
The agent process is expected to exit shortly after emitting the completion signal. When a child it spawned — a gh/git subprocess, a long-lived MCP server, etc. — inherits the agent's stdout pipe and keeps it open, the parent process can linger long past its logical end. Sandcastle would otherwise wait for the full idleTimeoutSeconds and fail with AgentIdleTimeoutError, throwing away the commits the agent already made.
Instead, once the completion signal is observed in the output buffer, Sandcastle swaps in a short completion timeout (default 60 s). When it expires, the run resolves successfully with a warning that the process was hanging; result.commits and result.completionSignal are populated as if the process had exited cleanly. The timer resets on every subsequent output line, so trailing data emitted after the signal — token-usage events, terminal result events, a structured-output <tag> — is still captured.
A clean process exit always wins the race, so healthy runs gain zero added latency. The completion timeout only matters when the process hangs.
Tune the window with completionTimeoutSeconds:
await run({
// ...
completionTimeoutSeconds: 30, // shorter grace window
});This is independent of idleTimeoutSeconds. They cover different phases: idleTimeoutSeconds runs before any signal is seen (genuinely stuck agent → fail); completionTimeoutSeconds runs after the signal is seen (hanging process → succeed with warning). See ADR 0019.
Structured output
Use Output.object() to extract a typed, schema-validated JSON payload from the agent's stdout. The agent emits its answer inside an XML tag you specify, and Sandcastle parses, validates, and returns it on result.output. The schema can be any Standard Schema validator — the examples below use Zod, but Valibot, ArkType, and others work identically. See ADR 0010 for design rationale.
import { run, Output, claudeCode } from "@chenshaohui6988/sandcastle";
import { docker } from "@chenshaohui6988/sandcastle/sandboxes/docker";
import { z } from "zod";
const result = await run({
agent: claudeCode("claude-opus-4-8"),
sandbox: docker(),
prompt: `Analyze the code, and output the result as JSON inside <result> tags.
The result must match this schema:
{ summary: string; score: string }
`,
output: Output.object({
tag: "result",
schema: z.object({ summary: z.string(), score: z.number() }),
}),
});
console.log(result.output.summary); // typed as string
console.log(result.output.score); // typed as numberOutput.string({ tag }) extracts the tag contents as a plain string (trimmed, no JSON parsing). Both helpers require maxIterations to be 1 (the default). The resolved prompt must contain the configured opening tag literal.
When extraction or validation fails, run() throws a StructuredOutputError. Alongside tag, rawMatched, cause, commits, branch, and preservedWorktreePath, the error carries the sessionId (and sessionFilePath, when the session was captured) of the run that produced the bad output.
Pass maxRetries to have Sandcastle handle the retry loop for you. Each retry resumes the same agent session and feeds back a token-efficient description of the error, so the agent can re-emit a corrected tag without redoing the work. Retries require an agent provider that supports session resumption (claudeCode, codex, pi) — calling run() with maxRetries > 0 against a non-resumable provider (cursor, opencode, copilot) throws immediately.
const result = await run({
agent: claudeCode("claude-opus-4-8"),
sandbox: docker(),
prompt: "Analyze the code and emit JSON inside <result> tags.",
output: Output.object({
tag: "result",
schema: z.object({ summary: z.string(), score: z.number() }),
maxRetries: 2, // 2 retries on top of the initial attempt
}),
});If you need to drive the retry loop manually — for example, to customise the feedback prompt or rotate models on each attempt — leave maxRetries at its default of 0 and resume the failed session yourself:
import {
run,
Output,
StructuredOutputError,
} from "@chenshaohui6988/sandcastle";
try {
return await run({ ...opts, output });
} catch (e) {
if (e instanceof StructuredOutputError && e.sessionId) {
return await run({
...opts,
output,
resumeSession: e.sessionId,
prompt: `Your previous output failed: ${e.message}. Re-emit it inside <${e.tag}> tags.`,
});
}
throw e;
}Templates
sandcastle init prompts you to choose a sandbox provider (Docker, Podman, or no-sandbox), an issue tracker (GitHub Issues, Beads, or Custom), and a template, which scaffolds a ready-to-use prompt and main.mts suited to a specific workflow. If your project's package.json has "type": "module", the file will be named main.ts instead. Choosing Custom scaffolds the project in a deliberately broken-until-configured state plus a .sandcastle/SETUP_ISSUE_TRACKER.md prompt you feed to your coding agent, which wires up your own tracker by editing the scaffolded files in place. Five templates are available:
| Template | Description |
| ------------------------------ | ------------------------------------------------------------------------- |
| blank | Bare scaffold — write your own prompt and orchestration |
| simple-loop | Picks issues one by one and closes them |
| sequential-reviewer | Implements issues one by one, with a code review step after each |
| parallel-planner | Plans parallelizable issues, executes on separate branches, then merges |
| parallel-planner-with-review | Plans parallelizable issues, executes with per-branch review, then merges |
Select a template during sandcastle init when prompted, or re-run init in a fresh repo to try a different one.
CLI commands
sandcastle init
Scaffolds the .sandcastle/ config directory. This is the first command you run in a new repo. You choose a sandbox provider during init: Docker writes a Dockerfile, Podman writes a Containerfile, and no-sandbox writes no container file because the agent runs directly on the host. Init also writes a default single-repository .sandcastle/workspace.json, so sandcastle workspace plan/run has a starter candidate workspace without hand-authoring the config first. Init also writes .sandcastle/SKILL_ROUTER.md, a companion guide that tells agents how to choose the right skill flow and when to update AGENTS.md or CLAUDE.md before starting project work. Image build prompts are skipped when no-sandbox is selected.
Init detects your host package manager (npm, pnpm, yarn, or bun) from a packageManager field or lockfile, defaulting to npm. Templates whose main file imports a host dependency — the planner templates import Zod for their <plan> output
