Running AI-Generated Code at the Edge with Cloudflare Sandboxes
If your app already runs on Cloudflare, you can execute untrusted agent code right next to it — no separate container host or VPC. This builds an agent tool on the Cloudflare Sandbox SDK, from the Dockerfile and wrangler bindings to streamed output.
Key takeaways
- If your product already runs on Workers or Pages, the Sandbox SDK lets you execute untrusted agent code in the same account and region — no separate container host or VPC to stand up and patch.
- A sandbox is a Durable Object backed by a container. You export one `Sandbox` class, bind it in wrangler.jsonc, and address per-user isolation by passing a stable id to `getSandbox()`.
- The tool contract is identical to the Docker version: hand the model a code runtime, return stdout, stderr, and the exit code verbatim so it can read its own errors and retry.
- The trade-off you're buying is ops time for platform limits — cold starts, instances that sleep, per-account ceilings, and pricing that scales with wall-clock time. Know those before you commit.
Summary
TL;DR: If your app already lives on Cloudflare, you don't need a separate box to run AI-generated code on. The Sandbox SDK runs untrusted code in a container that's a Durable Object, co-located with your Worker. This builds the whole thing: the Dockerfile, the wrangler.jsonc bindings, a Worker that runs a code string with getSandbox() and streams the output back, and a Vercel AI SDK tool that lets an agent call it — per-user isolation, timeouts, and pricing notes included.
In part one we built a locked-down Docker sandbox and ran it on a host we owned. That's the right answer when you already have infrastructure and want the control. But it comes with a bill you pay in ops: a Docker daemon to keep alive, a box to patch, disk to watch, and a network path from your app to that box that you have to secure and pay for.
Here's the situation this article is for. Your product is already on Cloudflare. The Next.js app is on Pages, the API is a Worker, your data is in D1 or KV. An agent in that app now needs to run code — do some arithmetic, transform an uploaded CSV, check its own output. Standing up a separate Docker host in some other cloud, poking a hole in a VPC, and shipping code strings across the internet to it feels like a lot of moving parts to bolt onto a stack that was, until this feature, refreshingly serverless.
Cloudflare's answer is to run the code on the same platform your app runs on. The Sandbox SDK gives you a container you address from a Worker binding — no separate host, no cross-cloud network hop, same account, same billing. The execution sits next to the Worker that asked for it. This tutorial builds that: the container image, the bindings, a Worker that runs code and streams output back, and the agent tool that drives it.
Why we're doing this
The pitch is co-location. When your Worker and the code sandbox are the same platform, a whole category of work disappears.
You don't run a host. There's no VM to keep patched, no Docker daemon to restart when it wedges, no disk filling up with dead containers at 3am — the thing part one warned you to clean up in a finally block is now the platform's job. You don't build a network path either. A self-hosted sandbox on another cloud means either an execution endpoint exposed to the internet or a private link, both of them work to secure. Here the sandbox is a binding on env, reached the way you'd reach KV or a queue, and it never has a public address you defend.
Isolation is per-id and free to ask for. Each sandbox is keyed by a string you choose — pass a user's id and that user gets their own container, pass a session id and each session is walled off from the rest. You're not managing a pool or tracking which container belongs to whom; you hand getSandbox() a stable id and get back the one that matches, created the first time. And it scales the way the rest of your stack does: traffic up, more instances; traffic down, they go away, with no idle capacity you paid for in advance.
None of that is free of trade-offs, and I'd rather you know them now than discover them in production:
- Cold starts and sleeping. A container that hasn't run in a while goes to sleep, and the next call has to wake or recreate it. First-hit latency is real. For an interactive agent that's usually fine; for a latency-budget of a few hundred milliseconds, measure it.
- Platform limits. There's a ceiling on concurrent instances per account and on how big an instance can be (
instance_type). If your workload is thousands of simultaneous heavy executions, you'll hit numbers a self-hosted fleet wouldn't. - Pricing follows wall-clock time. You pay for how long containers run and the resources they use. A sandbox that idles cheaply is fine; one that runs multi-minute jobs at high volume adds up. Model it against a fixed-cost box before you assume edge is cheaper at your scale.
If those don't scare you off — and for most apps already on Cloudflare they won't — this is the shortest path from "my agent should run code" to shipping it.
Here's how the three approaches in this series line up:
| Approach | Isolation | What you operate | Best when |
|---|---|---|---|
| Self-hosted Docker (part 1) | Container on a host you own | The host, the daemon, cleanup, the network path | You already have infra and want full control |
| Cloudflare Sandbox (this article) | Container-backed Durable Object, per-id | Almost nothing — a Dockerfile and bindings | Your app already runs on Workers/Pages |
| microVM (Firecracker / managed) | A real kernel boundary | An API key, or the microVM host | Hostile multi-tenant input, hard isolation |
This article is the middle row. You write a Dockerfile and some bindings, and the operational surface mostly vanishes. If you need a kernel-level boundary for genuinely adversarial input, that's the microVM row, and the hardening article covers what that actually takes.
The architecture
A sandbox in this model is a container that happens to be a Durable Object. You export one class, Sandbox, from your Worker. Cloudflare backs each instance of that Durable Object with a container built from your Dockerfile. getSandbox(env.Sandbox, id) is how you get a handle to a specific one.
agent (Vercel AI SDK, in your app)
│ "run this code"
▼
POST /run ──► your Worker
│ getSandbox(env.Sandbox, userId)
▼
┌─────────────────────────────┐
│ Sandbox (Durable Object) │
│ = container from Dockerfile│
│ exec() / runCode() │
└─────────────────────────────┘
│ stdout / stderr / exitCode
▼
stream back to the agent
The important properties: the Worker and the sandbox are the same account, so there's no public execution endpoint and no cross-cloud hop. The id you pass to getSandbox() is the isolation boundary — same id, same container and its filesystem; different id, a different one. Unlike the Docker build in part one, where we threw the container away after every run, these persist across calls, which makes per-user state cheap and per-user isolation the default.
One version note: the npm package and the base image are versioned together. @cloudflare/sandbox@0.7.0 pairs with docker.io/cloudflare/sandbox:0.7.0, and a mismatch warns at startup, so pin both to the same number. The SDK is young and moves quickly — check the docs for the current release, and treat the version numbers here as of mid-2026 rather than eternal.
Step 1: Prerequisites and the project
You need a Cloudflare account and Wrangler. The fastest start is the official template, which wires the bindings and a working Dockerfile for you:
npm create cloudflare@latest -- code-sandbox \
--template=cloudflare/sandbox-sdk/examples/minimal
cd code-sandbox
If you'd rather add it to an existing Worker, install the package directly and build the config yourself (the rest of this article):
npm install @cloudflare/sandbox@0.7.0
Running the container locally needs Docker installed and its daemon up, same as part one — Wrangler builds and runs the image on your machine during wrangler dev. In production it's Cloudflare's problem, not yours.
Step 2: The Dockerfile
The container image is where your code actually runs. You build FROM Cloudflare's base image, which already sets the entrypoint that starts the sandbox's internal HTTP server — you do not override ENTRYPOINT. The -python variant ships a Python data-science stack, which is what most agents reach for.
# Dockerfile
# Match this tag to your @cloudflare/sandbox npm version.
FROM docker.io/cloudflare/sandbox:0.7.0-python
# Bake in whatever the agent's code will import. Do it here, not at
# runtime — installing at exec time is slow and needs network you may
# not want the sandboxed code to have.
RUN pip install --no-cache-dir pandas==2.2.2 numpy==2.0.1
# EXPOSE any port your own service inside the sandbox listens on. The
# SDK reserves its internal control port, so pick something else (8080)
# if you run a dev server; for pure code execution you can omit this.
EXPOSE 8080
Two things worth calling out. First, bake dependencies into the image rather than pip install-ing them when the agent runs — same lesson as part one's "custom image for real libraries" note, and here it also keeps you from having to grant the running code network access. Second, don't touch ENTRYPOINT. The base image's entrypoint is what boots the process the SDK talks to; override it and getSandbox() gets a container it can't drive.
Step 3: wrangler.jsonc bindings
Three pieces wire the container to your Worker: a containers entry pointing at the Dockerfile, a durable_objects binding that exposes the class on env, and a migration that registers the class as SQLite-backed. All three reference the same class_name, "Sandbox".
// wrangler.jsonc
{
"name": "code-sandbox",
"main": "src/index.ts",
"compatibility_date": "2026-07-01",
"containers": [
{
"class_name": "Sandbox",
"image": "./Dockerfile",
"instance_type": "lite",
"max_instances": 10
}
],
"durable_objects": {
"bindings": [
{ "class_name": "Sandbox", "name": "Sandbox" }
]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["Sandbox"] }
]
}
instance_type sizes each container — lite is the small tier; step it up when pandas needs the memory. max_instances is your concurrency cap and, indirectly, your cost ceiling: it's the most containers this Worker will run at once. The migration with new_sqlite_classes is a one-time registration; ship it once and leave it, adding new tags only when you change the class shape.
Step 4: The Worker
Now the Worker itself. Two lines do the structural work: you re-export the Sandbox class so Cloudflare can find the Durable Object, and you type env.Sandbox as its namespace. Everything else is a normal fetch handler.
// src/index.ts
import { getSandbox, type Sandbox } from "@cloudflare/sandbox";
// This re-export is mandatory. It's how the runtime discovers the
// Durable Object class named in wrangler.jsonc.
export { Sandbox } from "@cloudflare/sandbox";
type Env = {
Sandbox: DurableObjectNamespace<Sandbox>;
};
interface RunResult {
stdout: string;
stderr: string;
exitCode: number;
success: boolean;
}
The handler takes a POST with the code and a session id, gets the matching sandbox, runs the code, and returns the captured output. The session id is the whole isolation story — it's what decides which container the code lands in.
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (request.method === "POST" && url.pathname === "/run") {
const { code, sessionId } = await request.json<{
code: string;
sessionId: string;
}>();
// Same sessionId => same container (and its filesystem). Different
// users pass different ids and never see each other's state.
const sandbox = getSandbox(env.Sandbox, sessionId);
// Write the code to a file and run it. We pass the source as an
// argv entry to python3 rather than building a shell string, so
// there's no quoting to get wrong. Note: confirm the exact exec
// signature against the current docs, since the SDK is pre-1.0 —
// https://developers.cloudflare.com/sandbox/guides/execute-commands/
await sandbox.writeFile("/workspace/main.py", code);
const result = await sandbox.exec("python3 -I /workspace/main.py");
const body: RunResult = {
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
success: result.success,
};
return Response.json(body);
}
return new Response("POST code to /run", { status: 404 });
},
};
exec() runs the command and resolves with the whole result: stdout, stderr, an exitCode, and a success boolean. That's the same shape part one assembled by hand from Docker streams — here the SDK hands it to you. The -I flag is Python's isolated mode, ignoring environment variables and the user site directory, which is a cheap bit of hygiene worth keeping.
Streaming the output
For anything that takes more than a beat, you don't want the user staring at a spinner until the whole run finishes. If the code is Python or JavaScript and you want rich results — a printed table, a chart as a PNG — runCode() is the code-interpreter path, and it takes streaming callbacks:
// Stream output as it's produced instead of waiting for the end.
// Signatures on the code-interpreter API are still settling pre-1.0;
// check https://developers.cloudflare.com/sandbox/guides/code-execution/
const sandbox = getSandbox(env.Sandbox, sessionId);
const ctx = await sandbox.createCodeContext({ language: "python" });
await sandbox.runCode(code, {
context: ctx.id,
stream: true,
onOutput: (chunk) => {
// chunk arrives as the code prints. Push it down an SSE response
// or a WebSocket to the browser here.
writeToClient(chunk);
},
onResult: (final) => {
// final.outputs can carry a base64 png, html, or json — a chart
// the code drew, for instance — alongside final.output text.
if (final.outputs?.png) saveChart(final.outputs.png);
},
});
createCodeContext() gives you a persistent execution context, so variables and imports survive between runCode() calls that share the context id — handy for a back-and-forth analyst session, exactly the durable-state pattern from the E2B tutorial. stream: true with onOutput delivers text as it's produced; onResult hands you the final payload, including rich outputs like a chart PNG. Wrap the Worker response in an SSE stream or a WebSocket and the browser sees output land line by line.
Step 5: Wire it as an agent tool
The sandbox is running behind a Worker endpoint. The last step is letting an agent drive it, and the contract is identical to part one — a description the model reads, a schema it fills, a function you run. The only difference is the function does a fetch to the Worker instead of talking to Docker.
// tool.ts
import { tool } from "ai";
import { z } from "zod";
const SANDBOX_URL = process.env.SANDBOX_WORKER_URL!; // your Worker's URL
export function makePythonTool(sessionId: string) {
return tool({
description:
"Run Python in an isolated Cloudflare sandbox. Use this instead of " +
"doing arithmetic or data work in your head. Print anything you want " +
"to read back — only stdout and stderr come out.",
inputSchema: z.object({
code: z
.string()
.describe("A complete Python script. Use print() to return results."),
}),
execute: async ({ code }) => {
const res = await fetch(`${SANDBOX_URL}/run`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ code, sessionId }),
});
const { stdout, stderr, exitCode } = await res.json<{
stdout: string;
stderr: string;
exitCode: number;
}>();
// Hand the model the raw truth, traceback and all. That verbatim
// stderr is the feedback loop that lets it fix its own code.
return [
`exit code: ${exitCode}`,
stdout && `stdout:\n${stdout}`,
stderr && `stderr:\n${stderr}`,
]
.filter(Boolean)
.join("\n\n");
},
});
}
Note the tool is built per session — makePythonTool(sessionId) closes over the id, so every execution from this agent conversation lands in the same container. Two users chatting with your app at once get two ids, two containers, and zero chance of one reading the other's files.
If your agent itself runs in a Worker, you can skip the public fetch entirely and call getSandbox(env.Sandbox, sessionId) straight from the tool's execute, since the binding is right there on env. That's the co-location payoff in one line: no HTTP round trip, no endpoint to secure — the agent and its sandbox are the same deployment.
Dropping it into a generateText loop is the same as part one:
// agent.ts
import { generateText, stepCountIs } from "ai";
import { openai } from "@ai-sdk/openai";
import { makePythonTool } from "./tool";
async function main() {
const sessionId = "user-42-session-abc"; // stable per user session
const { text } = await generateText({
model: openai("gpt-4o"),
system:
"You are a careful engineer. When a question needs real computation, " +
"write a small Python script and run it with the python tool rather " +
"than working it out in your head. Read the tool output before you answer.",
prompt:
"A dataset has 4,812,033 rows. Our pipeline processes 1,750 rows per " +
"second. How long is a full pass, in hours and minutes? Compute it " +
"exactly — don't estimate.",
tools: { python: makePythonTool(sessionId) },
stopWhen: stepCountIs(6),
});
console.log(text);
}
main();
The model writes a few lines of Python, the tool ships them to the sandbox, the sandbox runs them next to your Worker, and the exact answer comes back. Change the prompt to anything a model would rather fake — a messy date calculation, a primality check, a column sum over an uploaded file — and the shape holds.
Production considerations
The build above works. A handful of things matter once real traffic hits it.
-
Make the sandbox id mean something. The id is your isolation boundary, so derive it from a real principal — a signed user id, a session token — not something a caller can spoof. If two requests should share state, they share an id; if they must be isolated, they must not. Getting this wrong is how one user reads another's uploaded file. Treat the id with the same care you'd treat a tenant key.
-
Budget for cold starts and sleeping. A cold or slept container adds real latency on the first call after idle. If your UX can't eat that, keep a warm sandbox per active session (they persist, so a periodic no-op call keeps one awake), and show a "starting up" state on the first execution rather than a frozen spinner.
-
Respect the limits, and set your own.
max_instancesin wrangler.jsonc caps concurrency, which caps cost — pick it deliberately. Wrap every execution in a wall-clock timeout on the tool side too; a model can write an infinite loop just as easily on the edge as on a host, and you don't want it billing wall-clock time until the platform's own ceiling trips. Watch the per-account instance limits if you're multi-tenant at scale. -
Keep secrets out of the sandbox. The code running there is untrusted — that was the whole premise. Don't pass API keys, database URLs, or tokens into the container's environment, and don't mount anything sensitive into
/workspace. If the sandboxed code needs a result that requires a secret, compute it in the Worker (which holds the secret) and pass only the result in. The Worker is trusted; the container is not. Keep that line bright. -
Log every run. Store the code, the exit code, whether it timed out, and the duration. A spike in non-zero exits or timeouts is your early signal that a prompt-injection attempt is underway or a model change started generating garbage. A security control nobody watches is decoration.
Frequently Asked Questions (FAQ)
How is this different from just running my own Docker sandbox? Operationally, a lot; conceptually, very little. Both run untrusted code in a container. The self-hosted version from part one gives you total control and makes you responsible for the host, the daemon, cleanup, and the network path to it. The Sandbox SDK trades that control for the platform running all of it — you write a Dockerfile and bindings and skip the ops. Pick self-hosted when you already have infra and want the knobs; pick this when your app is already on Cloudflare and you'd rather not add a second system.
Is a Cloudflare sandbox isolated enough for hostile input? It's a strong boundary for the common case — your own agent, your own users, non-adversarial input — and per-id isolation keeps tenants apart cleanly. For deliberately hostile, multi-tenant input from strangers actively trying to break out, you want to understand the exact isolation guarantees of the current platform version and likely layer defenses on top, which is the territory the hardening article walks through. Don't assume any single boundary is airtight; design as if code will occasionally misbehave.
How do I give each user their own isolated environment?
Pass a per-user or per-session string as the second argument to getSandbox(env.Sandbox, id). Same id, same container and filesystem; different id, a fresh isolated one. Derive that id from an authenticated identity so a caller can't pass someone else's id and land in their container. That single string is the entire multi-tenant story.
What happens to a sandbox between requests — does state persist?
Yes, and that's the point. Unlike the per-run throwaway container in part one, a Sandbox Durable Object persists across calls, so files you wrote and a createCodeContext() you opened are still there on the next request with the same id. Idle containers sleep to save cost and wake on the next call, which is where cold-start latency comes from. If you need a clean slate, use a fresh id or delete the sandbox's working files explicitly.
Can I run something other than Python?
Yes. exec() runs any command the image has — swap python3 for node, or run a compiled binary you baked into the Dockerfile. runCode()'s code-interpreter path currently targets Python and JavaScript with automatic rich-output capture. Choose the base image variant that fits (-python for data work, plain for JS/TS) and install whatever else you need in the Dockerfile.
Where this goes next
You've now seen two ends of the spectrum: a container you run and harden yourself, and a container the platform runs for you right next to your Worker. Both spin up a real OS process to execute code, which is powerful and, for a lot of workloads, more than you need.
The next article goes the other direction — lighter, not heavier. When the code is small and the trust bar is "don't let it touch my host," you can skip containers entirely and run it in a WebAssembly sandbox inside the same process, with Pyodide for Python and QuickJS for JavaScript. No image to build, no instance to wake, microsecond startup. It's the third rung, and it's the one that fits neatly inside a single Worker request: running AI code in-process with WebAssembly, Pyodide, and QuickJS.
Related reading
A locked-down container stops the obvious attacks. Here are the controls that stop a determined one: a non-root user, a tight seccomp profile, resource ulimits, a network egress allowlist, and a real kernel boundary with gVisor.
One-shot code execution can't build software. An autonomous coding agent needs a filesystem that survives across turns — to install dependencies, edit files, run the test suite, read the failures, and open a pull request. This builds that with a persistent E2B sandbox.
When you can't run a container — serverless functions, the edge, cold-start-sensitive paths — you can still isolate untrusted code in-process with WebAssembly. This runs LLM-written Python via Pyodide and JavaScript via QuickJS, each with hard memory and time limits.
Let's build something great.
Have a project in mind? We are an elite software and AI development studio ready to bring your ideas to production. Let's talk about your roadmap.