Skip to content

Move other 0.x features

Last updated View as MarkdownAgent setup

This page replaces or removes the Sandbox SDK 0.12 features that have no class in 1.0. Each section stands alone. Follow only the sections for the features that your application uses.

Before you start

Your 1.0 class replaces the 0.12 Sandbox class, and has the container getter, the ensureRunning() and startContainer() methods, and the ENV constant from Replace the Sandbox class. A class that you reach through this.ctx.exports needs compatibility date 2025-11-17 or later, or the enable_ctx_exports flag.

Each 0.12 feature has one section:

0.12 feature Section
Docker in a docker:dind-rootless image Run Docker inside the sandbox
outboundByHost handlers that read bindings Reach Workers bindings from the container
@cloudflare/sandbox/opencode Start an OpenCode server
@cloudflare/sandbox/openai Give OpenAI agents a shell and an editor
@cloudflare/sandbox/bridge Replace the bridge
WarmPool Remove WarmPool

Run Docker inside the sandbox

The 0.x Docker setup starts from docker:dind-rootless, switches to USER root, and copies in the musl build of the 0.x server. On a 1.0 container, its Docker daemon exits with failed to set IP forwarding '/proc/sys/net/ipv4/ip_forward' = '1': open /proc/sys/net/ipv4/ip_forward: read-only file system.

  1. Replace your Dockerfile:

    Dockerfiledockerfile
    FROM docker:dind
    
    COPY --from=docker.io/cloudflare/sandbox:1.0.0 /usr/local/bin/sandbox-shim /usr/local/bin/sandbox-shim
    
    ENTRYPOINT ["sh", "-c", "dockerd-entrypoint.sh dockerd --iptables=false --ip6tables=false --ip-forward=false > /var/log/dockerd.log 2>&1 & exec sleep infinity"]

    The Docker daemon runs as root, with iptables and IP forwarding turned off. It logs to /var/log, because the docker:dind entrypoint mounts an empty /tmp. sleep infinity keeps the container running. Keep the COPY line only if your class uses Files or S3Mount. sandbox-shim is statically linked, so it runs on the Alpine-based docker:dind image.

  2. Keep enableInternet: true in start(), so that Docker can pull images. 0.12 allowed Internet access by default.

  3. Wait for Docker, and run containers with --network=host:

    src/index.ts (0.12)ts
    const sandbox = getSandbox(env.Sandbox, "docker-sandbox");
    const run = await sandbox.exec(
    	"docker run --rm --network=host alpine:3.22 " +
    		"echo Hello from Docker",
    );
    src/index.ts (1.0)ts
    export class MySandbox extends DurableObject<Env> {
    	// ...
    
    	private async waitForDocker(): Promise<void> {
    		const wait = await this.container.exec([
    			"timeout",
    			"30",
    			"sh",
    			"-c",
    			"until docker version > /dev/null 2>&1; do sleep 0.2; done",
    		]);
    
    		if ((await wait.exitCode) !== 0) {
    			throw new Error("Docker did not start within 30 seconds");
    		}
    	}
    
    	async runImage(): Promise<string> {
    		await this.ensureRunning();
    		// The Docker daemon starts after the container, so wait for it
    		await this.waitForDocker();
    
    		const run = await this.container.exec(
    			[
    				"docker",
    				"run",
    				"--rm",
    				"--network=host",
    				"alpine:3.22",
    				"echo",
    				"Hello from Docker",
    			],
    			{ env: ENV },
    		);
    		const result = await run.output();
    		return new TextDecoder().decode(result.stdout);
    	}
    }

    Without --network=host, docker run fails with failed to disable IPv6 on container's interface eth0. A docker build step that uses the network, such as a package install, also needs docker build --network=host. An inner container started this way shares the network access of the sandbox.

  4. Call runImage() from your Worker. It returns Hello from Docker. The first call also pulls alpine:3.22, and Docker writes its progress to stderr.

For more information, refer to Docker-in-Docker in the Containers FAQ.

Reach Workers bindings from the container

In 0.12, an outboundByHost handler answered requests to a hostname such as my.kv, and read Workers bindings from its env argument. In 1.0, a WorkerEntrypoint answers them, and your Durable Object registers it for that hostname.

  1. Replace each handler with a WorkerEntrypoint, and export it from the main module of your Worker:

    src/index.ts (0.12)ts
    MySandbox.outboundByHost = {
    	"my.kv": async (request: Request, env: Env) => {
    		const key = new URL(request.url).pathname.slice(1);
    		const value = await env.KV.get(key);
    		return new Response(value ?? "", {
    			status: value ? 200 : 404,
    		});
    	},
    };
    src/index.ts (1.0)ts
    import { WorkerEntrypoint } from "cloudflare:workers";
    
    type KvProps = { containerId: string };
    
    export class KvGateway extends WorkerEntrypoint<Env, KvProps> {
    	async fetch(request: Request): Promise<Response> {
    		const key = new URL(request.url).pathname.slice(1);
    		const value = await this.env.KV.get(key);
    		return new Response(value ?? "", {
    			status: value === null ? 404 : 200,
    		});
    	}
    }

    this.env has the same bindings as the env argument. Where a 0.12 handler read ctx.containerId, read this.ctx.props.containerId.

  2. Register the entrypoint in the try block of startContainer():

    src/index.tsts
    export class MySandbox extends DurableObject<Env> {
    	// ...
    
    	private async startContainer(): Promise<void> {
    		const newContainer = !this.container.running;
    
    		if (newContainer) {
    			this.container.start({
    				image: this.container.images.sandbox,
    				instance: "standard-1",
    				env: ENV,
    				// 0.12 allowed Internet access by default.
    				enableInternet: true,
    			});
    		}
    
    		try {
    			if (newContainer) {
    				// Replaces onStart().
    				await this.ctx.storage.put("startedAt", Date.now());
    			}
    			await this.container.interceptOutboundHttp(
    				"my.kv",
    				this.ctx.exports.KvGateway({
    					props: { containerId: this.ctx.id.toString() },
    				}),
    			);
    			await this.container.setInactivityTimeout(
    				INACTIVITY_TIMEOUT_MS,
    			);
    		} catch (error) {
    			// The next request starts a new container.
    			await this.container.destroy();
    			throw error;
    		}
    	}
    }

    this.ctx.id.toString() is the value that 0.12 passed as containerId, so keys and paths built from it stay the same. The intercept also works with enableInternet: false. Register one entrypoint for each hostname.

    If your class also uses the Outbound entrypoint from Move outbound rules, add the handler to its HANDLERS map instead. A hostname intercept that you register after interceptAllOutboundHttp() receives no requests.

  3. Request the hostname from the container. After you store hello from KV under the key greeting, this command prints the value:

    node -e 'fetch("http://my.kv/greeting").then(async (r) => console.log(r.status, await r.text()))'
    200 hello from KV

Start an OpenCode server

In 0.12, createOpencode() started opencode serve in the sandbox, and returned an OpenCode SDK client that reached it through the sandbox. In 1.0, your Durable Object starts the server and creates the client, with a fetch function that connects to the server port.

  1. Install OpenCode in your Dockerfile, as in Run OpenCode in a sandbox:

    Dockerfiledockerfile
    RUN npm install --global --ignore-scripts opencode-linux-x64-baseline@1.18.32 \
    	&& ln -s /usr/local/lib/node_modules/opencode-linux-x64-baseline/bin/opencode /usr/local/bin/opencode

    The 0.12 opencode image included OpenCode. Start the container with the standard-1 instance type or larger, as startContainer() does. On lite, the server did not answer requests within 30 seconds.

  2. Install the OpenCode SDK in your Worker project:

    npm i @opencode-ai/sdk@1.18.32
  3. Replace createOpencode() with a method that starts the server once for each container. It uses the scripts and functions from step 1 of Run a server in the background:

    src/index.ts (0.12)ts
    const sandbox = getSandbox(env.Sandbox, "my-agent");
    const { client } = await createOpencode(sandbox, {
    	directory: "/workspace",
    	config: OPENCODE_CONFIG,
    });
    const session = await client.session.create({ title: "Fix tests" });
    src/index.ts (1.0)ts
    import {
    	createOpencodeClient,
    	type OpencodeClient,
    } from "@opencode-ai/sdk/v2/client";
    
    const OPENCODE_PORT = 4096;
    const OPENCODE_DIR = `${SERVERS}/opencode`;
    
    // Your OpenCode configuration, without apiKey values.
    const OPENCODE_CONFIG = {};
    
    const OPENCODE = [
    	"opencode",
    	"serve",
    	"--port",
    	String(OPENCODE_PORT),
    	"--hostname",
    	"0.0.0.0",
    ];
    src/index.ts (1.0)ts
    export class MySandbox extends DurableObject<Env> {
    	// ...
    
    	private opencodeReady = false;
    
    	private async openCode(): Promise<OpencodeClient> {
    		await this.ensureRunning();
    
    		if (!this.opencodeReady) {
    			await startServer(this.container, OPENCODE_DIR, OPENCODE, {
    				cwd: "/workspace",
    				env: {
    					...ENV,
    					OPENCODE_CONFIG_CONTENT:
    						JSON.stringify(OPENCODE_CONFIG),
    					ANTHROPIC_API_KEY: this.env.ANTHROPIC_API_KEY,
    				},
    			});
    			await waitForServer(
    				this.container,
    				OPENCODE_DIR,
    				OPENCODE_PORT,
    				30_000,
    			);
    			this.opencodeReady = true;
    		}
    
    		const port = this.container.getTcpPort(OPENCODE_PORT);
    		return createOpencodeClient({
    			baseUrl: "http://opencode",
    			fetch: (request) => port.fetch(request),
    		});
    	}
    
    	async createSession(title: string): Promise<string> {
    		const client = await this.openCode();
    		const session = await client.session.create({ title });
    
    		if (!session.data) {
    			throw new Error("OpenCode did not create the session");
    		}
    
    		return session.data.id;
    	}
    }

    Remove apiKey from your OpenCode configuration, and store each key as a secret. Like 0.12, the method passes the configuration as OPENCODE_CONFIG_CONTENT and the key in a provider variable such as ANTHROPIC_API_KEY. The container can read the key. To keep model credentials in your Worker, refer to Run OpenCode in a sandbox.

    Set this.opencodeReady = false in startContainer(), inside if (newContainer), because a new container has no server. If your Durable Object restarts while the container runs, startServer() finds the server that already runs. Use the client in methods of your class, such as createSession().

  4. Serve the OpenCode web UI from the fetch() handler of your Durable Object:

    src/index.tsts
    export class MySandbox extends DurableObject<Env> {
    	// ...
    
    	async fetch(request: Request): Promise<Response> {
    		const url = new URL(request.url);
    		const accept = request.headers.get("Accept") ?? "";
    		const page =
    			accept.includes("text/html") || url.pathname === "/";
    
    		// The UI calls the server at the origin in ?url=.
    		if (
    			request.method === "GET" &&
    			page &&
    			!url.searchParams.has("url")
    		) {
    			url.searchParams.set("url", url.origin);
    			return Response.redirect(url.toString(), 302);
    		}
    
    		await this.openCode();
    		// The container port accepts only http: URLs.
    		url.protocol = "http:";
    		return this.container
    			.getTcpPort(OPENCODE_PORT)
    			.fetch(new Request(url, request));
    	}
    }

    This replaces proxyToOpencode(), which made the same redirect. The UI loads its files from /, so serve it at the root of a hostname and forward every path to your Durable Object, for example with env.Sandbox.getByName("my-agent").fetch(request). The UI can run commands in the sandbox, so authenticate these requests in your Worker.

  5. Call createSession("Fix tests") from your Worker. It returns the new session ID, such as ses_f1822cedcffe1CpcMlwM9eQSGZ. Open your Worker URL in a browser. It redirects to /?url= and loads OpenCode.

Give OpenAI agents a shell and an editor

In 0.12, Shell and Editor from @cloudflare/sandbox/openai ran the commands and file edits of an OpenAI agent in a sandbox, while the agent ran in your Worker. In 1.0, you write both classes over container.exec() and Files, and run the agent in your Durable Object, where the container is.

  1. Add a Shell that runs each command with bash in /workspace:

    src/index.tsts
    import { Files } from "@cloudflare/sandbox";
    import {
    	Agent,
    	applyDiff,
    	applyPatchTool,
    	run,
    	shellTool,
    	type ApplyPatchOperation,
    	type ApplyPatchResult,
    	type Editor,
    	type Shell,
    	type ShellAction,
    	type ShellResult,
    } from "@openai/agents";
    
    class SandboxShell implements Shell {
    	constructor(private readonly container: Container) {}
    
    	async run(action: ShellAction): Promise<ShellResult> {
    		const timeoutMs = action.timeoutMs ?? 60_000;
    		const seconds = String(Math.ceil(timeoutMs / 1000));
    		const limit = ["timeout", "--kill-after=5", seconds];
    		const output: ShellResult["output"] = [];
    		const decoder = new TextDecoder();
    
    		for (const command of action.commands) {
    			const process = await this.container.exec(
    				[...limit, "bash", "-c", command],
    				{ cwd: "/workspace", env: ENV },
    			);
    			const result = await process.output();
    			const timedOut = result.exitCode === 124;
    
    			output.push({
    				stdout: decoder.decode(result.stdout),
    				stderr: decoder.decode(result.stderr),
    				outcome: timedOut
    					? { type: "timeout" }
    					: { type: "exit", exitCode: result.exitCode },
    			});
    
    			// 0.12 also skipped the rest after a timeout.
    			if (timedOut) {
    				break;
    			}
    		}
    
    		return {
    			output,
    			providerData: { working_directory: "/workspace" },
    		};
    	}
    }

    timeout stops the command and every process it started, and the command exits with code 124. 0.12 set no time limit when the action had no timeoutMs, and this class sets 60 seconds.

  2. Add an Editor that applies the patches from the agent with Files:

    src/index.tsts
    type Operation<T> = Extract<ApplyPatchOperation, { type: T }>;
    
    class SandboxEditor implements Editor {
    	constructor(private readonly files: Files) {}
    
    	// Reads each path as relative to /workspace, as 0.12 did, and throws
    	// when `..` leaves /workspace.
    	private path(path: string): string {
    		const root = "/workspace";
    		const inRoot = path === root || path.startsWith(`${root}/`);
    		const relative = inRoot ? path.slice(root.length) : path;
    		const segments: string[] = [];
    
    		for (const segment of relative.split("/")) {
    			if (segment === "..") {
    				if (segments.pop() === undefined) {
    					throw new Error(`Operation outside workspace: ${path}`);
    				}
    			} else if (segment !== "" && segment !== ".") {
    				segments.push(segment);
    			}
    		}
    
    		return [root, ...segments].join("/");
    	}
    
    	async createFile(
    		operation: Operation<"create_file">,
    	): Promise<ApplyPatchResult> {
    		const path = this.path(operation.path);
    		const parent = path.slice(0, path.lastIndexOf("/"));
    		await this.files.mkdir(parent, { recursive: true });
    		const content = applyDiff("", operation.diff, "create");
    		await this.files.writeFile(path, content);
    		return {
    			status: "completed",
    			output: `Created ${operation.path}`,
    		};
    	}
    
    	async updateFile(
    		operation: Operation<"update_file">,
    	): Promise<ApplyPatchResult> {
    		const path = this.path(operation.path);
    		const file = await this.files.readFile(path);
    		const current = await file.text();
    		const content = applyDiff(current, operation.diff);
    		await this.files.writeFile(path, content);
    		return {
    			status: "completed",
    			output: `Updated ${operation.path}`,
    		};
    	}
    
    	async deleteFile(
    		operation: Operation<"delete_file">,
    	): Promise<ApplyPatchResult> {
    		await this.files.remove(this.path(operation.path));
    		return {
    			status: "completed",
    			output: `Deleted ${operation.path}`,
    		};
    	}
    }

    path() reads /etc/passwd as /workspace/etc/passwd, and throws for ../etc/passwd, as 0.12 did. It checks the path only. A symbolic link in /workspace can still point outside it. For more information, refer to Accept paths from callers.

    A failed Files call throws SandboxFileError. Both 0.12 classes kept a results list, and the 0.12 Editor added a failed result to it before it threw the error again. If your code read the list, record results in each method.

  3. Run the agent in a method of your class:

    src/index.ts (0.12)ts
    const sandbox = getSandbox(env.Sandbox, `session-${sessionId}`);
    const agent = new Agent({
    	name: "Sandbox agent",
    	instructions: INSTRUCTIONS,
    	tools: [
    		shellTool({ shell: new Shell(sandbox) }),
    		applyPatchTool({
    			editor: new Editor(sandbox, "/workspace"),
    		}),
    	],
    });
    const result = await run(agent, input);
    src/index.ts (1.0)ts
    export class MySandbox extends DurableObject<Env> {
    	// ...
    
    	private readonly files = new Files(this.container);
    
    	async runAgent(input: string): Promise<string | undefined> {
    		await this.ensureRunning();
    
    		const shell = new SandboxShell(this.container);
    		const editor = new SandboxEditor(this.files);
    		const agent = new Agent({
    			name: "Sandbox agent",
    			instructions: INSTRUCTIONS,
    			tools: [shellTool({ shell }), applyPatchTool({ editor })],
    		});
    		const result = await run(agent, input);
    		return result.finalOutput;
    	}
    }

    Your Worker calls runAgent() on the stub that getByName() returns. The Agents SDK reads OPENAI_API_KEY from the environment, as it did in your Worker.

    If your class already has the files field from Change file calls, keep one of the two.

  4. Check both classes without a model by calling them directly. shell.run({ commands: ["pwd", "sleep 10"], timeoutMs: 2000 }) returns the working directory, then a timeout:

    {
    	"output": [
    		{
    			"stdout": "/workspace\n",
    			"stderr": "",
    			"outcome": { "type": "exit", "exitCode": 0 }
    		},
    		{
    			"stdout": "",
    			"stderr": "",
    			"outcome": { "type": "timeout" }
    		}
    	],
    	"providerData": { "working_directory": "/workspace" }
    }

    editor.createFile({ type: "create_file", path: "notes/todo.md", diff: "+first\n+second\n" }) returns { "status": "completed", "output": "Created notes/todo.md" }. /workspace/notes/todo.md then contains the lines first and second.

Replace the bridge

The 0.12 bridge, bridge() from @cloudflare/sandbox/bridge, served an HTTP API over Sandbox calls for clients outside Workers, and checked a SANDBOX_API_KEY bearer token. 1.0 has no bridge. Add a Worker route for each call that your client makes, and have the route call a method of your Durable Object. Check the token in your Worker, as the bridge did.

Clients written for the bridge API, such as CloudflareSandboxClient in the OpenAI Agents SDK for Python, work only with a 0.x bridge Worker. Keep that Worker on 0.x until your client calls your own routes.

Remove WarmPool

Start each sandbox when a request needs it, and remove WarmPool. The pool kept 0.x sandboxes started ahead of time, because a 0.x start took seconds. On 1.0, a sandbox on a prepared image runs its first command in under 600 ms at the median.

  1. Remove the WarmPool export from the main module of your Worker, and its binding from durable_objects in the Wrangler configuration.

  2. Delete the class in the same way your Worker added it. If your Worker uses migrations, add a migration:

    {
    	"migrations": [
    		{ "new_sqlite_classes": ["Sandbox"], "tag": "v1" },
    		{ "new_sqlite_classes": ["WarmPool"], "tag": "v2" },
    		{ "deleted_classes": ["WarmPool"], "tag": "v3" },
    	],
    }
    [[migrations]]
    new_sqlite_classes = [ "Sandbox" ]
    tag = "v1"
    
    [[migrations]]
    new_sqlite_classes = [ "WarmPool" ]
    tag = "v2"
    
    [[migrations]]
    deleted_classes = [ "WarmPool" ]
    tag = "v3"

    If your Worker uses exports, mark the class as deleted instead:

    {
    	"exports": {
    		"WarmPool": {
    			"type": "durable-object",
    			"state": "deleted",
    		},
    	},
    }
    [exports.WarmPool]
    type = "durable-object"
    state = "deleted"
  3. Remove the WARM_POOL_TARGET, WARM_POOL_REFRESH_INTERVAL, WARM_POOL_MAX_INSTANCES, and WARM_POOL_SCALE_BATCH_SIZE variables. bridge() filled the pool from its scheduled() handler, so remove the cron trigger too if nothing else uses it.

The sandboxes that the pool started are 0.12 sandboxes, so Plan the move covers them like any other.

If your application still needs those last milliseconds, call start() for the sandbox that a user is about to use, for example when they open a session. Do not keep a pool of started sandboxes. Each one is billed while it waits, and your code must assign it to a session.

Was this helpful?