# How to Create a Custom Sandbox Provider in Sandcastle Using createIsolatedSandboxProvider

> Learn to create a custom sandbox provider in Sandcastle using createIsolatedSandboxProvider. Implement IsolatedSandboxHandle for isolated agent execution.

- Repository: [Matt Pocock/sandcastle](https://github.com/mattpocock/sandcastle)
- Tags: how-to-guide
- Published: 2026-05-24

---

**Create a custom sandbox provider by implementing the `IsolatedSandboxHandle` interface—defining `exec`, `copyIn`, `copyFileOut`, and `close` methods—and wrapping it with `createIsolatedSandboxProvider` from [`src/SandboxProvider.ts`](https://github.com/mattpocock/sandcastle/blob/main/src/SandboxProvider.ts) to enable isolated agent execution in any environment.**

Sandcastle treats sandboxes as plugins that manage the lifecycle of code execution environments. When you need to integrate a custom runtime—whether a local temporary directory, a Docker container, or a remote cloud VM—you can build a compliant provider using the `createIsolatedSandboxProvider` helper. This function is exported from **[`src/SandboxProvider.ts`](https://github.com/mattpocock/sandcastle/blob/main/src/SandboxProvider.ts)** in the mattpocock/sandcastle repository and implements the `IsolatedSandboxProvider` interface required by the framework.

## Understanding the Isolated Sandbox Contract

The core contract for isolated sandboxes is defined in **[`src/SandboxProvider.ts`](https://github.com/mattpocock/sandcastle/blob/main/src/SandboxProvider.ts)**. According to the source code, an isolated provider must satisfy four operational requirements:

1. **Create** a sandbox handle for a given worktree.
2. **Execute commands** inside the sandbox while optionally streaming output line-by-line.
3. **Transfer files** between the host filesystem and the sandbox.
4. **Tear down** resources when the session ends.

The `IsolatedSandboxProvider` interface expects a `create` function that returns an `IsolatedSandboxHandle`. This handle must expose five specific properties and methods: `worktreePath` (string), `exec`, `copyIn`, `copyFileOut`, and `close`. The provider object returned by `createIsolatedSandboxProvider` includes a discriminant `tag: "isolated"` that allows Sandcastle to dispatch the correct implementation path at runtime.

## Step-by-Step Implementation Guide

### 1. Define Provider Configuration (Optional)

If your sandbox requires configuration—such as API keys, endpoint URLs, or resource limits—define a TypeScript interface similar to `DaytonaOptions` found in **[`src/sandboxes/daytona.ts`](https://github.com/mattpocock/sandcastle/blob/main/src/sandboxes/daytona.ts)**. This options interface is passed to your provider factory function.

### 2. Invoke createIsolatedSandboxProvider

Import `createIsolatedSandboxProvider` from [`../SandboxProvider.js`](https://github.com/mattpocock/sandcastle/blob/main/../SandboxProvider.js) (or the package export). This helper constructs the provider object and handles environment merging with the global `EnvResolver` system. You must provide:
- `name`: A string identifier for the provider.
- `env` (optional): Default environment variables injected into the sandbox.
- `create`: An async factory function that builds and returns the `IsolatedSandboxHandle`.

### 3. Implement the Sandbox Handle

The `create` function must return an object implementing `IsolatedSandboxHandle` with these methods:

- **`worktreePath`**: Absolute path to the working directory inside the sandbox.
- **`exec(command, opts)`**: Executes shell commands. Must support an optional `onLine` callback in `opts` to stream stdout line-by-line for live feedback and idle-timeout enforcement.
- **`copyIn(hostPath, sandboxPath)`**: Recursively copies files or directories from the host into the sandbox.
- **`copyFileOut(sandboxPath, hostPath)`**: Copies a single file from the sandbox back to the host (host path is absolute).
- **`close()`**: Cleans up resources (removes temp directories, stops containers, or terminates VMs).

### 4. Export the Provider

Export a factory function (e.g., `myFsIsolated`) that returns the `IsolatedSandboxProvider`. Users import this function and pass its result to `run()`, `interactive()`, or `createSandbox()`.

## Complete Example: Filesystem-Based Provider

The following implementation creates a temporary directory-based sandbox, following the pattern used in **[`src/sandboxes/test-isolated.ts`](https://github.com/mattpocock/sandcastle/blob/main/src/sandboxes/test-isolated.ts)**. It demonstrates streaming execution, recursive file copying, and proper cleanup.

```typescript
// src/sandboxes/my-fs-isolated.ts
import {
  createIsolatedSandboxProvider,
  type IsolatedSandboxProvider,
  type IsolatedSandboxHandle,
  type ExecResult,
} from "../SandboxProvider.js";

export const myFsIsolated = (): IsolatedSandboxProvider =>
  createIsolatedSandboxProvider({
    name: "my-fs-isolated",
    env: { MY_VAR: "value" },

    create: async (): Promise<IsolatedSandboxHandle> => {
      const { mkdtemp, rm, mkdir, cp, stat } = await import("node:fs/promises");
      const { tmpdir } = await import("node:os");
      const { join, dirname } = await import("node:path");
      
      const sandboxRoot = await mkdtemp(join(tmpdir(), "my-sandbox-"));
      const worktreePath = join(sandboxRoot, "workspace");
      await mkdir(worktreePath, { recursive: true });

      return {
        worktreePath,

        exec: (command, opts) => {
          const { execFile, spawn } = await import("node:child_process");
          const { createInterface } = await import("node:readline");

          if (opts?.onLine) {
            const onLine = opts.onLine;
            return new Promise<ExecResult>((resolve, reject) => {
              const proc = spawn("sh", ["-c", command], {
                cwd: opts?.cwd ?? worktreePath,
                stdio: ["ignore", "pipe", "pipe"],
              });

              const stdoutLines: string[] = [];
              const stderrChunks: string[] = [];

              const rl = createInterface({ input: proc.stdout! });
              rl.on("line", (line) => {
                stdoutLines.push(line);
                onLine(line);
              });

              proc.stderr!.on("data", (c) => stderrChunks.push(c.toString()));
              proc.on("error", (e) => reject(e));
              proc.on("close", (code) =>
                resolve({
                  stdout: stdoutLines.join("\n"),
                  stderr: stderrChunks.join(""),
                  exitCode: code ?? 0,
                })
              );
            });
          }

          return new Promise<ExecResult>((resolve, reject) => {
            execFile(
              "sh",
              ["-c", command],
              { cwd: opts?.cwd ?? worktreePath },
              (error, stdout, stderr) => {
                if (error && error.code === undefined) reject(error);
                else
                  resolve({
                    stdout: stdout.toString(),
                    stderr: stderr.toString(),
                    exitCode: typeof error?.code === "number" ? error.code : 0,
                  });
              }
            );
          });
        },

        copyIn: async (hostPath, sandboxPath) => {
          const info = await stat(hostPath);
          if (info.isDirectory())
            await cp(hostPath, sandboxPath, { recursive: true });
          else {
            await mkdir(dirname(sandboxPath), { recursive: true });
            await cp(hostPath, sandboxPath);
          }
        },

        copyFileOut: async (sandboxPath, hostPath) => {
          await mkdir(dirname(hostPath), { recursive: true });
          await cp(sandboxPath, hostPath);
        },

        close: async () => {
          await rm(sandboxRoot, { recursive: true, force: true });
        },
      };
    },
  });

```

To use this provider in a Sandcastle execution:

```typescript
import { run } from "sandcastle";
import { myFsIsolated } from "./sandboxes/my-fs-isolated.js";

await run({
  sandboxProvider: myFsIsolated(),
  // Additional run configuration (prompt, model, etc.)
});

```

## Remote API Integration Pattern

For remote sandboxes (e.g., cloud VMs), follow the architecture in **[`src/sandboxes/daytona.ts`](https://github.com/mattpocock/sandcastle/blob/main/src/sandboxes/daytona.ts)**. The skeleton remains identical, but the `create` function initializes an API client instead of a local directory:

```typescript
export const myRemote = (opts?: MyRemoteOptions): IsolatedSandboxProvider =>
  createIsolatedSandboxProvider({
    name: "my-remote",
    env: opts?.env,
    create: async () => {
      const client = new RemoteClient(opts?.apiKey, opts?.apiUrl);
      const sandbox = await client.startSandbox(opts?.config);
      const worktreePath = await sandbox.getWorkDir();

      return {
        worktreePath,
        exec: (command, opts) => client.exec(command, opts),
        copyIn: (hostPath, sandboxPath) => client.uploadFile(hostPath, sandboxPath),
        copyFileOut: (sandboxPath, hostPath) => client.downloadFile(sandboxPath, hostPath),
        close: () => client.stopSandbox(sandbox.id),
      };
    },
  });

```

## Key Architectural Details

**Streaming Execution**: The `exec` method must call `opts.onLine` for each line of stdout as soon as it is produced. Sandcastle uses this callback to enforce idle timeouts and provide real-time feedback. If `onLine` is not provided, the method should still return an `ExecResult` containing the full stdout, stderr, and exit code.

**Environment Merging**: Any `env` declared in the provider config is merged with the global environment resolver (see **[`src/EnvResolver.ts`](https://github.com/mattpocock/sandcastle/blob/main/src/EnvResolver.ts)**) before the sandbox launches. This allows users to override defaults while preserving system-wide configuration.

**Resource Lifecycle**: The `close` method must be idempotent and thorough. Sandcastle automatically invokes `close` when the sandbox is disposed, but your implementation must handle partial failures gracefully to avoid leaking temporary directories or orphaning remote VMs.

## Summary

- Import `createIsolatedSandboxProvider` from **[`src/SandboxProvider.ts`](https://github.com/mattpocock/sandcastle/blob/main/src/SandboxProvider.ts)** to build compliant isolated providers.
- Implement the five required handle methods: `worktreePath`, `exec`, `copyIn`, `copyFileOut`, and `close`.
- Support the `onLine` callback in `exec` to enable Sandcastle's streaming output and idle detection features.
- Use **[`src/sandboxes/test-isolated.ts`](https://github.com/mattpocock/sandcastle/blob/main/src/sandboxes/test-isolated.ts)** as a reference for filesystem-based sandboxes and **[`src/sandboxes/daytona.ts`](https://github.com/mattpocock/sandcastle/blob/main/src/sandboxes/daytona.ts)** for remote API patterns.
- Export a factory function that returns the provider, then pass it to `run()` or `createSandbox()`.

## Frequently Asked Questions

### What is the difference between an isolated provider and other sandbox types in Sandcastle?

**Isolated providers** create completely separate execution environments with their own filesystems and process spaces, requiring explicit `copyIn` and `copyFileOut` operations for file transfer. According to the source code in [`src/SandboxProvider.ts`](https://github.com/mattpocock/sandcastle/blob/main/src/SandboxProvider.ts), isolated providers are distinguished by the `tag: "isolated"` property and implement handle-based lifecycle management. Other provider types may offer different isolation guarantees or file access patterns, but isolated providers are required when you need guaranteed separation between the host and agent execution contexts.

### How do I handle long-running processes or timeouts in the exec method?

When implementing `exec` in your custom provider, you must respect the `onLine` callback to prevent idle timeouts. Sandcastle monitors this callback activity to determine if the process is still alive and producing output. Your implementation should spawn the process with streaming stdout, pipe each line to `onLine` immediately, and resolve the returned promise only when the process exits. Ensure you handle process termination signals properly in the `close` method to avoid zombie processes.

### Can I use environment variables from the host machine in my custom sandbox?

Yes, but they are merged through Sandcastle's environment resolution system. When you specify an `env` object in `createIsolatedSandboxProvider`, those values are combined with the global environment resolver defined in [`src/EnvResolver.ts`](https://github.com/mattpocock/sandcastle/blob/main/src/EnvResolver.ts). The merged environment is then injected into the sandbox. You should not assume direct access to `process.env` inside the sandbox handle unless you explicitly pass those values through the provider configuration.

### Where should I place my custom provider files in the project structure?

Create your provider implementation in `src/sandboxes/<your-provider>.ts` following the convention established by [`daytona.ts`](https://github.com/mattpocock/sandcastle/blob/main/daytona.ts) and [`test-isolated.ts`](https://github.com/mattpocock/sandcastle/blob/main/test-isolated.ts) in the Sandcastle repository. This location keeps sandbox implementations organized alongside the core types. If you are consuming Sandcastle as a dependency rather than modifying the source, you can place the provider file anywhere in your project and import `createIsolatedSandboxProvider` from the `sandcastle` package instead of a relative path.