In Sandbox SDK 0.12, runCode() ran Python, JavaScript, and TypeScript in an interpreter process for each code context, and variables stayed defined between calls. 1.0 has no interpreter. This page runs Python in an IPython kernel that your Durable Object starts, with the results that 0.12 returned, and runs JavaScript in a Dynamic Worker.
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. To keep variables between calls, src/index.ts also needs the scripts and functions from step 1 of Run a server in the background.
A runCode() call without a context used a default context for its language, so plain calls in one sandbox shared variables:
const sandbox = getSandbox(env.Sandbox, "ada");
await sandbox.runCode("sales = [120, 95]");
const execution = await sandbox.runCode("sum(sales)");If your code relies on this, or on contexts that you create, follow Keep Python variables between calls. If the code in each call stands alone, start a new python3 process for each call, as in Run Python code. The other 0.12 methods have their own sections:
| 0.12 | Section |
|---|---|
runCode() with language: "javascript" or "typescript" |
Run JavaScript in a Dynamic Worker |
runCodeStream(), onStdout, onStderr, onResult |
Replace streaming output |
A kernel is a Python process that runs the code of each call with IPython, and keeps its variables until it exits. Your Durable Object starts one kernel for each context, on its own port.
-
Add IPython and the packages the code imports to your
Dockerfile, and copy in the kernel script:Dockerfiledockerfile FROM node:24-trixie-slim RUN apt-get update \ && apt-get install -y --no-install-recommends \ ca-certificates git python3 \ python3-ipython python3-matplotlib \ python3-numpy python3-pandas \ && rm -rf /var/lib/apt/lists/* COPY --from=docker.io/cloudflare/sandbox:1.0.0 /usr/local/bin/sandbox-shim /usr/local/bin/sandbox-shim WORKDIR /workspace COPY kernel.py /opt/kernel.py CMD ["sleep", "infinity"]The 0.12
pythonimage included IPython, matplotlib, NumPy, and pandas. Debian packages install them withoutpip. -
Create
kernel.pynext to yourDockerfile:kernel.pypython import base64 import io import json import os import signal import sys import traceback from http.server import BaseHTTPRequestHandler from socketserver import TCPServer os.environ["MPLBACKEND"] = "Agg" from IPython.core.interactiveshell import InteractiveShell from IPython.utils.capture import capture_output PORT = int(sys.argv[1]) FORMATS = { "text/plain": "text", "text/html": "html", "text/markdown": "markdown", "text/latex": "latex", "image/png": "png", "image/jpeg": "jpeg", "image/svg+xml": "svg", "application/json": "json", "application/javascript": "javascript", } shell = InteractiveShell.instance(colors="NoColor") # Return values and errors in the response, not in stdout. shell.displayhook.write_output_prompt = lambda: None shell.displayhook.write_format_data = lambda *a, **k: None shell.showtraceback = lambda *a, **k: None shell.showsyntaxerror = lambda *a, **k: None running = False def interrupt(signum, frame): # Stop a running cell. An idle kernel ignores the signal. if running: raise KeyboardInterrupt def formats(data): return { FORMATS[kind]: value for kind, value in data.items() if kind in FORMATS } def figures(): if "matplotlib.pyplot" not in sys.modules: return [] import matplotlib.pyplot as plt images = [] for number in plt.get_fignums(): buffer = io.BytesIO() plt.figure(number).savefig( buffer, format="png", bbox_inches="tight" ) png = base64.b64encode(buffer.getvalue()).decode() images.append({"png": png}) plt.close("all") return images def describe(error): if error is None: return None if isinstance(error, SyntaxError): lines = traceback.format_exception_only(error) else: # Skip the frame of the kernel itself. lines = traceback.format_exception( type(error), error, error.__traceback__.tb_next ) return { "name": type(error).__name__, "message": str(error), "traceback": lines, } def run(code): global running running = True try: with capture_output(display=True) as captured: cell = shell.run_cell(code, store_history=True) finally: running = False results = [formats(output.data) for output in captured.outputs] results += figures() if isinstance(cell.result, (dict, list)): results.append({"json": cell.result}) elif cell.result is not None: data, _ = shell.display_formatter.format(cell.result) results.append(formats(data)) stdout, stderr = captured.stdout, captured.stderr return { "logs": { "stdout": [stdout] if stdout else [], "stderr": [stderr] if stderr else [], }, "results": results, "error": describe( cell.error_before_exec or cell.error_in_exec ), "executionCount": cell.execution_count, } class Kernel(BaseHTTPRequestHandler): def do_GET(self): self.reply(b"ready") def do_POST(self): length = int(self.headers["Content-Length"]) code = self.rfile.read(length).decode() result = json.dumps(run(code), default=repr) self.reply(result.encode()) def reply(self, body): self.send_response(200) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def log_message(self, *args): pass signal.signal(signal.SIGINT, interrupt) # HTTPServer looks up the container hostname, which is too # long to resolve. TCPServer.allow_reuse_address = True TCPServer(("0.0.0.0", PORT), Kernel).serve_forever()The kernel runs the body of each
POSTrequest as an IPython cell, and returns what 0.12 returned: standard output and standard error, one result for each value that the cell displays, a PNG for each open matplotlib figure, the value of the last expression, and the error. It handles one request at a time, so calls to one context wait for each other, as they did in 0.12. -
Add the result and context types before your class:
src/index.tsts type Execution = { logs: { stdout: string[]; stderr: string[] }; // One object for each value, keyed by format. results: Record<string, string | object>[]; error: { name: string; message: string; traceback: string[]; } | null; executionCount: number; }; type CodeContext = { id: string; port: number; cwd: string; env?: Record<string, string>; }; const DEFAULT_CONTEXT: CodeContext = { id: "default", port: 8800, cwd: "/workspace", }; // Each kernel runs as a server in its own directory. const kernelDir = (context: CodeContext) => `${SERVERS}/kernel-${context.port}`; -
Add methods to your class that start a kernel and interrupt it:
src/index.tsts export class MySandbox extends DurableObject<Env> { // ... // Ports of the kernels that answered in this container. private kernels = new Set<number>(); private async startKernel( context: CodeContext, ): Promise<Fetcher> { if (this.kernels.has(context.port)) { return this.container.getTcpPort(context.port); } await startServer( this.container, kernelDir(context), ["python3", "/opt/kernel.py", String(context.port)], { cwd: context.cwd, env: { ...ENV, ...context.env } }, ); const kernel = await waitForServer( this.container, kernelDir(context), context.port, 30_000, ); this.kernels.add(context.port); return kernel; } private async interruptKernel(context: CodeContext): Promise<void> { const kill = await this.container.exec([ "sh", "-c", `${CURRENT}\ncurrent "$1" && kill -INT "$pid"`, "sh", kernelDir(context), ]); await kill.exitCode; } }Call
this.kernels.clear()instartContainer(), insideif (newContainer), because a new container has no kernels. If your Durable Object restarts while the container runs,startServer()finds the kernel that already runs, and the kernel keeps its variables. If a kernel exits,waitForServer()throws with the end of its log. -
Add a
runCode()method:src/index.tsts export class MySandbox extends DurableObject<Env> { // ... async runCode( code: string, options: { context?: string; timeout?: number } = {}, ): Promise<Execution> { await this.ensureRunning(); const context = await this.getContext( options.context ?? DEFAULT_CONTEXT.id, ); const kernel = await this.startKernel(context); try { const response = await kernel.fetch("http://kernel/", { method: "POST", body: code, signal: AbortSignal.timeout(options.timeout ?? 60_000), }); return await response.json<Execution>(); } catch (error) { if ((error as Error).name === "TimeoutError") { // Stop the cell, and keep the kernel variables. await this.interruptKernel(context); } else { // The kernel exited. The next call starts a new one. this.kernels.delete(context.port); } throw error; } } }0.12 had no default timeout, and a cell that timed out kept running. Here, a call that runs longer than
timeoutthrows aTimeoutError, and the kernel stops the cell withKeyboardInterrupt. Variables that earlier calls defined stay. If the kernel exits, for example because the code runs out of memory, the call throws and the next call starts a kernel without variables. -
Add methods that replace
createCodeContext(),listCodeContexts(), anddeleteCodeContext():src/index.tsts export class MySandbox extends DurableObject<Env> { // ... private async getContext(id: string): Promise<CodeContext> { if (id === DEFAULT_CONTEXT.id) { return DEFAULT_CONTEXT; } const context = await this.ctx.storage.get<CodeContext>( `context:${id}`, ); if (!context) { throw new Error(`No code context ${id}`); } return context; } async createCodeContext( options: { cwd?: string; env?: Record<string, string> } = {}, ): Promise<CodeContext> { // Take the lowest port that no stored context uses. const stored = await this.ctx.storage.list<CodeContext>({ prefix: "context:", }); const used = new Set([...stored.values()].map((c) => c.port)); let port = DEFAULT_CONTEXT.port + 1; while (used.has(port)) { port++; } const context: CodeContext = { id: crypto.randomUUID(), port, cwd: options.cwd ?? "/workspace", env: options.env, }; await this.ctx.storage.put(`context:${context.id}`, context); return context; } async listCodeContexts(): Promise<CodeContext[]> { const contexts = await this.ctx.storage.list<CodeContext>({ prefix: "context:", }); return [DEFAULT_CONTEXT, ...contexts.values()]; } async deleteCodeContext(id: string): Promise<void> { const context = await this.getContext(id); if (this.container.running) { await stopServer(this.container, kernelDir(context)); this.kernels.delete(context.port); } await this.ctx.storage.delete(`context:${id}`); } }createCodeContext()gives each new context the lowest port after8800that no stored context uses, so a new context can take the port of a deleted one.The contexts share one container, so code in one context can read the files of the others and reach their ports. Give each user their own sandbox name.
-
Replace each call. Pass the
idof the context, andenvforenvVars:src/index.ts (0.12)ts const sandbox = getSandbox(env.Sandbox, "ada"); const context = await sandbox.createCodeContext({ language: "python", envVars: { REGION: "east" }, }); const execution = await sandbox.runCode( "import os; os.environ['REGION']", { context, timeout: 30_000 }, );src/index.ts (1.0)ts const sandbox = env.Sandbox.getByName("ada"); const context = await sandbox.createCodeContext({ env: { REGION: "east" }, }); const execution = await sandbox.runCode( "import os; os.environ['REGION']", { context: context.id, timeout: 30_000 }, );Then change the code that reads the result:
0.12 1.0 results[i].text,html,png,jpeg,svg,latex,markdown,javascript,jsonThe same keys. 0.12 returned each format as its own result, such as one htmlresult and onetextresult for a pandas DataFrame. The kernel returns one result with both keys.results[i].formats()Object.keys(results[i]).results[i].chart,results[i].dataNot returned. The 0.12 Python interpreter did not set them. logs.stdout,logs.stderrThe same, with one string for each call. 0.12 also wrote the value of the last line to stdout, such asOut[0]: 215. The kernel returns that value only inresults.error.name,error.message,error.tracebackThe same. errorisnullwhen the code succeeds, notundefined.executionCountThe same. -
Deploy, and send two calls to one sandbox. In this example, a Worker route at
/sandboxes/<name>/codepasses the request body torunCode():WORKER="https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev" curl "$WORKER/sandboxes/ada/code" --data-binary 'sales = [120, 95]' curl "$WORKER/sandboxes/ada/code" --data-binary 'sum(sales)'The second call reads the variable that the first call defined:
{ "logs": { "stdout": [], "stderr": [] }, "results": [{ "text": "215" }], "error": null, "executionCount": 2 }
Variables that 0.12 contexts held stay in the 0.12 container. After the switch, run your setup code again, and create your contexts again, because 0.12 context IDs are not in the storage of your Durable Object.
Run JavaScript in a Dynamic Worker that your Worker loads, so JavaScript needs no container.
-
Add a Worker Loader binding to your Wrangler configuration:
{ "worker_loaders": [ { "binding": "LOADER", }, ], }[[worker_loaders]] binding = "LOADER"npx wrangler typesyarn wrangler typespnpm wrangler types -
Create
src/sandbox.tswith therunJavaScript()function from Build an AI code interpreter. -
Replace each
runCode()call for JavaScript:src/index.ts (0.12)ts const execution = await sandbox.runCode( "[2, 3, 5, 7].reduce((a, b) => a + b)", { language: "javascript" }, );src/index.ts (1.0)ts import { runJavaScript } from "./sandbox"; const output = await runJavaScript( env, "return [2, 3, 5, 7].reduce((a, b) => a + b);", );outputis{ "result": 17, "logs": [] }, andlogsholds theconsole.log()output. The code runs as the body of an async function, so it mustreturnits result. 0.12 returned the value of the last expression instead. The code has no network access. For the limits thatrunJavaScript()sets, refer to Build an AI code interpreter.
Each call loads a new Dynamic Worker, so variables do not carry over. Put the values that the code needs into the code. 0.12 compiled TypeScript before running it. To run TypeScript, compile it first with @cloudflare/worker-bundler.
The kernel returns the output of a cell when the cell finishes, so runCodeStream(), onStdout, onStderr, and onResult have no 1.0 version. To send output while code runs, run the code as a command with python3 -, pass it on standard input, and return stdout from the process, as in Move commands. A command does not keep variables.