AIHero

    Reference

    Custom sandbox providers

    Edit this page on GitHub
    On this page

    Sandcastle ships with built-in providers for Docker, Podman, and Vercel, but you can create your own. A sandbox provider tells Sandcastle how to execute commands in an isolated environment. There are two kinds:

    • Bind-mount — the sandbox can mount a host directory. Sandcastle creates a worktree on the host and the provider mounts it in. No file sync needed. Use this for Docker, Podman, or any local container runtime.
    • Isolated — the sandbox has its own filesystem (e.g. a cloud VM). The provider handles syncing code in and out via copyIn and copyFileOut. Use this when the sandbox cannot access the host filesystem.

    The sandbox handle contract

    Both provider types return a sandbox handle from their create() function. The handle exposes:

    MethodRequiredDescription
    execBothRun a command, optionally streaming stdout line-by-line via options.onLine
    closeBothTear down the sandbox
    copyFileInBind-mountCopy a single file from the host into the sandbox
    copyFileOutBothCopy a single file from the sandbox to the host
    copyInIsolatedCopy a file or directory from the host into the sandbox
    worktreePathBothAbsolute path to the repo directory inside the sandbox

    ExecResult

    Every exec call returns an ExecResult:

    interface ExecResult {
    readonly stdout: string;
    readonly stderr: string;
    readonly exitCode: number;
    }

    Bind-mount provider example

    A minimal bind-mount provider that shells out to local processes (no container):

    import {
    createBindMountSandboxProvider,
    type BindMountCreateOptions,
    type BindMountSandboxHandle,
    type ExecResult,
    } from "@ai-hero/sandcastle";
    import { execFile, spawn } from "node:child_process";
    import { copyFile as fsCopyFile, mkdir as fsMkdir } from "node:fs/promises";
    import { dirname } from "node:path";
    import { createInterface } from "node:readline";
    const localProcess = () =>
    createBindMountSandboxProvider({
    name: "local-process",
    create: async (
    options: BindMountCreateOptions,
    ): Promise<BindMountSandboxHandle> => {
    const worktreePath = options.worktreePath;
    return {
    worktreePath,
    exec: (
    command: string,
    opts?: { onLine?: (line: string) => void; cwd?: string },
    ): Promise<ExecResult> => {
    if (opts?.onLine) {
    const onLine = opts.onLine;
    return new Promise((resolve, reject) => {
    const proc = spawn("sh", ["-c", command], {
    cwd: opts?.cwd ?? worktreePath,
    stdio: ["ignore", "pipe", "pipe"],
    });
    const stdoutChunks: string[] = [];
    const stderrChunks: string[] = [];
    const rl = createInterface({ input: proc.stdout! });
    rl.on("line", (line) => {
    stdoutChunks.push(line);
    onLine(line); // forward each line to Sandcastle
    });
    proc.stderr!.on("data", (chunk: Buffer) => {
    stderrChunks.push(chunk.toString());
    });
    proc.on("error", (err) => reject(err));
    proc.on("close", (code) => {
    resolve({
    stdout: stdoutChunks.join("\n"),
    stderr: stderrChunks.join(""),
    exitCode: code ?? 0,
    });
    });
    });
    }
    return new Promise((resolve, reject) => {
    execFile(
    "sh",
    ["-c", command],
    { cwd: opts?.cwd ?? worktreePath, maxBuffer: 10 * 1024 * 1024 },
    (error, stdout, stderr) => {
    if (error && error.code === undefined) {
    reject(new Error(`exec failed: ${error.message}`));
    } else {
    resolve({
    stdout: stdout.toString(),
    stderr: stderr.toString(),
    exitCode: typeof error?.code === "number" ? error.code : 0,
    });
    }
    },
    );
    });
    },
    copyFileIn: async (hostPath: string, sandboxPath: string) => {
    await fsMkdir(dirname(sandboxPath), { recursive: true });
    await fsCopyFile(hostPath, sandboxPath);
    },
    copyFileOut: async (sandboxPath: string, hostPath: string) => {
    await fsMkdir(dirname(hostPath), { recursive: true });
    await fsCopyFile(sandboxPath, hostPath);
    },
    close: async () => {
    // nothing to tear down for a local process
    },
    };
    },
    });

    Isolated provider example

    A minimal isolated provider using a temp directory:

    import {
    createIsolatedSandboxProvider,
    type IsolatedSandboxHandle,
    type ExecResult,
    } from "@ai-hero/sandcastle";
    import { execFile, spawn } from "node:child_process";
    import { copyFile, mkdir, mkdtemp, rm } from "node:fs/promises";
    import { tmpdir } from "node:os";
    import { dirname, join } from "node:path";
    import { createInterface } from "node:readline";
    const tempDir = () =>
    createIsolatedSandboxProvider({
    name: "temp-dir",
    create: async (): Promise<IsolatedSandboxHandle> => {
    const root = await mkdtemp(join(tmpdir(), "sandbox-"));
    const worktreePath = join(root, "workspace");
    await mkdir(worktreePath, { recursive: true });
    return {
    worktreePath,
    exec: (
    command: string,
    opts?: { onLine?: (line: string) => void; cwd?: string },
    ): Promise<ExecResult> => {
    if (opts?.onLine) {
    const onLine = opts.onLine;
    return new Promise((resolve, reject) => {
    const proc = spawn("sh", ["-c", command], {
    cwd: opts?.cwd ?? worktreePath,
    stdio: ["ignore", "pipe", "pipe"],
    });
    const stdoutChunks: string[] = [];
    const stderrChunks: string[] = [];
    const rl = createInterface({ input: proc.stdout! });
    rl.on("line", (line) => {
    stdoutChunks.push(line);
    onLine(line);
    });
    proc.stderr!.on("data", (chunk: Buffer) => {
    stderrChunks.push(chunk.toString());
    });
    proc.on("error", (err) => reject(err));
    proc.on("close", (code) => {
    resolve({
    stdout: stdoutChunks.join("\n"),
    stderr: stderrChunks.join(""),
    exitCode: code ?? 0,
    });
    });
    });
    }
    return new Promise((resolve, reject) => {
    execFile(
    "sh",
    ["-c", command],
    { cwd: opts?.cwd ?? worktreePath, maxBuffer: 10 * 1024 * 1024 },
    (error, stdout, stderr) => {
    if (error && error.code === undefined) {
    reject(new Error(`exec failed: ${error.message}`));
    } else {
    resolve({
    stdout: stdout.toString(),
    stderr: stderr.toString(),
    exitCode: typeof error?.code === "number" ? error.code : 0,
    });
    }
    },
    );
    });
    },
    copyIn: async (hostPath: string, sandboxPath: string) => {
    const info = await stat(hostPath);
    if (info.isDirectory()) {
    await cp(hostPath, sandboxPath, { recursive: true });
    } else {
    await mkdir(dirname(sandboxPath), { recursive: true });
    await copyFile(hostPath, sandboxPath);
    }
    },
    copyFileOut: async (sandboxPath: string, hostPath: string) => {
    await mkdir(dirname(hostPath), { recursive: true });
    await copyFile(sandboxPath, hostPath);
    },
    close: async () => {
    await rm(root, { recursive: true, force: true });
    },
    };
    },
    });

    Branch strategies

    A branch strategy controls where the agent's commits land. Configure it when constructing the provider:

    StrategyBehaviorBind-mountIsolated
    headAgent writes directly to the host working directory. No worktree createdDefaultN/A
    merge-to-headSandcastle creates a temp branch, merges back to HEAD when doneSupportedDefault
    branchCommits land on an explicit named branch you provideSupportedSupported

    When to use each:

    • head — fast iteration during development. No branch indirection, no merge step. Only works with bind-mount providers since the agent needs direct host filesystem access.
    • merge-to-head — safe default for automation. The agent works on a throwaway branch; if something goes wrong, HEAD is untouched. Use this for CI or unattended runs.
    • branch — when you want commits on a specific branch (e.g. for a PR). Pass { type: "branch", branch: "agent/fix-42" }.

    Branch strategy is now configured on run(), not on the provider:

    import { run, claudeCode } from "@ai-hero/sandcastle";
    import { docker } from "@ai-hero/sandcastle/sandboxes/docker";
    // head — direct write, bind-mount only (default for bind-mount providers)
    await run({
    agent: claudeCode("claude-opus-4-8"),
    sandbox: docker(),
    prompt: "…",
    });
    // merge-to-head — temp branch, merge back (default for isolated providers)
    await run({
    agent: claudeCode("claude-opus-4-8"),
    sandbox: tempDir(),
    prompt: "…",
    });
    // branch — explicit named branch
    await run({
    agent: claudeCode("claude-opus-4-8"),
    sandbox: docker(),
    branchStrategy: { type: "branch", branch: "agent/fix-42" },
    prompt: "…",
    });

    Passing to run()

    Pass your custom provider via the sandbox option — it works the same as the built-in docker() provider:

    import { run, claudeCode } from "@ai-hero/sandcastle";
    const result = await run({
    agent: claudeCode("claude-opus-4-8"),
    sandbox: localProcess(), // your custom provider
    prompt: "Fix issue #42 in this repo.",
    });

    Reference implementations

    For real-world examples, see: