Replace the code interpreter
Last updated Oct 23, 2026
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.
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. 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:
src/index.ts (0.12)ts
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 |
Keep Python variables between calls
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
```dockerfile 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.1 /usr/local/bin/sandbox-shim /usr/local/bin/sandbox-shim
WORKDIR /workspace COPY kernel.py /opt/kernel.py CMD ["sleep", "infinity"] ```
The 0.12 python image included IPython, matplotlib, NumPy, and pandas. Debian packages install them without pip.
2. Create kernel.py next to your Dockerfile:
kernel.pypython
```python 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 POST request 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.
3. Add the result and context types before your class:
src/index.tsts
```ts
type Execution = {
logs: { stdout: string[]; stderr: string[] };
// One object for each value, keyed by format.
results: Record
type CodeContext = {
id: string;
port: number;
cwd: string;
env?: Record
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
```ts
export class MySandbox extends DurableObject
// Ports of the kernels that answered in this container.
private kernels = new Set
private async startKernel(
context: CodeContext,
): Promise
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${CURRENT}\ncurrent "$1" && kill -INT "$pid",
"sh",
kernelDir(context),
]);
await kill.exitCode;
}
}
```
Call this.kernels.clear() in startContainer(), inside if (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.
5. Add a method that runs the calls to one context one at a time, and a runCode() method:
src/index.tsts
```ts
export class MySandbox extends DurableObject
// The last call queued for each context.
private queues = new Map<string, Promise
private inContext
async runCode(
code: string,
options: { context?: string; timeout?: number } = {},
): Promise
return this.inContext(id, async () => { await this.ensureRunning(); const context = await this.getContext(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
