In this tutorial, you will build a Worker that runs a coding agent on a GitHub repository in a Linux sandbox. You send a prompt, the agent edits the repository inside the sandbox, and you read its changes as a diff. The runner uses Claude Code ↗︎. The pages in Coding agents switch it to other agents.
Claude Code calls Anthropic models through AI Gateway. Your Worker adds the gateway token to those requests, so the token never enters the sandbox. The sandbox can reach only your gateway and github.com.
When you finish, you can ask the agent to add a file to a repository and read its change:
diff --git a/NOTES.md b/NOTES.md
new file mode 100644
index 0000000..95da11e
--- /dev/null
+++ b/NOTES.md
@@ -0,0 +1 @@
+This repository is a minimal test/example repo containing only a "Hello World!" README file.You will learn how to:
- Keep model credentials in your Worker while an agent runs in a sandbox.
- Allow a sandbox to reach only the hostnames a task needs.
- Run a task that lasts several minutes in the background and keep the sandbox running until it ends.
- Read the outcome and changes of the agent after the task ends.
- Sign up for a Cloudflare account ↗︎.
- Install
Node.js↗︎.
Node.js version manager
Use a Node version manager like Volta ↗︎ or nvm ↗︎ to avoid permission issues and change Node.js versions. Wrangler, discussed later in this guide, requires a Node version of 16.17.0 or later.
You must have Docker running locally when you run wrangler deploy. For most people, the best way to install Docker is to follow the docs for installing Docker Desktop ↗︎. Other tools like Colima ↗︎ may also work.
You can check that Docker is running properly by running the docker info command in your terminal. If Docker is running, the command will succeed. If Docker is not running,
the docker info command will hang or return an error including the message "Cannot connect to the Docker daemon".
You also need:
- An authenticated AI Gateway and a gateway token with the Run permission.
- Anthropic credentials in AI Gateway, either Unified Billing credits or an Anthropic API key stored as a provider key.
- Your Cloudflare account ID.
-
Create a Worker project:
npm create cloudflare@latest -- sandbox-coding-agent --category=hello-world --type=hello-world --lang=ts --no-deploy --no-git --no-agentsyarn create cloudflare sandbox-coding-agent --category=hello-world --type=hello-world --lang=ts --no-deploy --no-git --no-agentspnpm create cloudflare@latest sandbox-coding-agent --category=hello-world --type=hello-world --lang=ts --no-deploy --no-git --no-agents -
Change into the project directory:
cd sandbox-coding-agent -
Install the
@cloudflare/sandboxpackage and Zod ↗︎:npm i @cloudflare/sandbox zodyarn add @cloudflare/sandbox zodpnpm add @cloudflare/sandbox zodbun add @cloudflare/sandbox zod -
Create a
Dockerfilein your project root:Dockerfiledockerfile FROM node:24-trixie-slim RUN apt-get update \ && apt-get install -y --no-install-recommends bash ca-certificates git ripgrep \ && rm -rf /var/lib/apt/lists/* # The install script from the Claude Code package puts its native binary in place RUN npm install --global @anthropic-ai/claude-code@2.1.280 COPY --from=docker.io/cloudflare/sandbox:1.0.0 /usr/local/bin/sandbox-shim /usr/local/bin/sandbox-shim WORKDIR /workspace # Keep the container running between requests CMD ["sleep", "infinity"]Install everything the agent needs in the image, because the sandbox cannot download packages at run time.
Pin the Claude Code version. Its command-line flags and event format can change between releases. Use the
cloudflare/sandboxtag that matches the@cloudflare/sandboxversion you installed. -
Replace
wrangler.jsonc. Replace<ACCOUNT_ID>with your account ID, anddefaultwith your gateway ID if it is different:{ "$schema": "node_modules/wrangler/config-schema.json", "name": "sandbox-coding-agent", "main": "src/index.ts", // Set this to today's date "compatibility_date": "2026-10-01", "compatibility_flags": ["nodejs_compat"], "observability": { "enabled": true, }, "upload_source_maps": true, "vars": { "AI_GATEWAY_ACCOUNT_ID": "<ACCOUNT_ID>", "AI_GATEWAY_ID": "default", "MODEL": "claude-sonnet-5", }, "secrets": { "required": ["AI_GATEWAY_TOKEN"], }, "containers": [ { "class_name": "AgentSandbox", "scheduling_policy": "durable_object", "images": { "agent": { "dockerfile": "./Dockerfile", }, }, }, ], "durable_objects": { "bindings": [ { "class_name": "AgentSandbox", "name": "SANDBOX", }, ], }, "exports": { "AgentSandbox": { "type": "durable-object", "storage": "sqlite", }, }, }"$schema" = "node_modules/wrangler/config-schema.json" name = "sandbox-coding-agent" main = "src/index.ts" # Set this to today's date compatibility_date = "2026-10-01" compatibility_flags = [ "nodejs_compat" ] upload_source_maps = true [observability] enabled = true [vars] AI_GATEWAY_ACCOUNT_ID = "<ACCOUNT_ID>" AI_GATEWAY_ID = "default" MODEL = "claude-sonnet-5" [secrets] required = [ "AI_GATEWAY_TOKEN" ] [[containers]] class_name = "AgentSandbox" scheduling_policy = "durable_object" [containers.images.agent] dockerfile = "./Dockerfile" [[durable_objects.bindings]] class_name = "AgentSandbox" name = "SANDBOX" [exports.AgentSandbox] type = "durable-object" storage = "sqlite"Each sandbox is an
AgentSandboxDurable Object with its own container. Wrangler builds theDockerfilewhen you deploy, and the Durable Object starts the built image asthis.ctx.container.images.agent.MODELis an Anthropic model ID that your gateway can serve.secrets.requiredmakeswrangler deployfail if the gateway token is missing. -
Generate types for the bindings, variables, and secret:
npx wrangler typesyarn wrangler typespnpm wrangler types
In the next three sections, you create src/outbound.ts and src/sandbox.ts and replace src/index.ts. The tutorial adds or replaces these files:
- Dockerfile
- wrangler.jsonc
- src
- outbound.ts
- sandbox.ts
- index.ts
Create src/outbound.ts. The sandbox sends every HTTP request on port 80 and HTTPS request on port 443 through this entrypoint in your Worker. Without Internet access, the sandbox cannot reach other ports:
import { WorkerEntrypoint } from "cloudflare:workers";
const gatewayHost = "gateway.ai.cloudflare.com";
export class Outbound extends WorkerEntrypoint {
async fetch(request) {
const url = new URL(request.url);
const gatewayPath = `/v1/${this.env.AI_GATEWAY_ACCOUNT_ID}/${this.env.AI_GATEWAY_ID}`;
if (url.protocol !== "https:") {
return new Response(`${url.hostname} is reachable only over HTTPS\n`, {
status: 403,
});
}
if (
url.hostname === gatewayHost &&
(url.pathname === gatewayPath ||
url.pathname.startsWith(`${gatewayPath}/`))
) {
const headers = new Headers(request.headers);
headers.delete("x-api-key");
headers.set(
"cf-aig-authorization",
`Bearer ${this.env.AI_GATEWAY_TOKEN}`,
);
return fetch(new Request(request, { headers }));
}
if (url.hostname === "github.com") {
return fetch(request);
}
return new Response(
`${url.hostname} is not reachable from this sandbox\n`,
{ status: 403 },
);
}
}import { WorkerEntrypoint } from "cloudflare:workers";
const gatewayHost = "gateway.ai.cloudflare.com";
export class Outbound extends WorkerEntrypoint<Env> {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
const gatewayPath = `/v1/${this.env.AI_GATEWAY_ACCOUNT_ID}/${this.env.AI_GATEWAY_ID}`;
if (url.protocol !== "https:") {
return new Response(`${url.hostname} is reachable only over HTTPS\n`, {
status: 403,
});
}
if (
url.hostname === gatewayHost &&
(url.pathname === gatewayPath ||
url.pathname.startsWith(`${gatewayPath}/`))
) {
const headers = new Headers(request.headers);
headers.delete("x-api-key");
headers.set(
"cf-aig-authorization",
`Bearer ${this.env.AI_GATEWAY_TOKEN}`,
);
return fetch(new Request(request, { headers }));
}
if (url.hostname === "github.com") {
return fetch(request);
}
return new Response(
`${url.hostname} is not reachable from this sandbox\n`,
{ status: 403 },
);
}
}The entrypoint allows two destinations:
- Requests to your gateway under your account ID and gateway ID. The Worker removes the placeholder API key that Claude Code sends and adds the gateway token. AI Gateway then calls Anthropic with the credentials it holds. If the placeholder stays, AI Gateway forwards it to Anthropic, and the model request fails.
- Requests to
github.com, so Git can clone public repositories.
The entrypoint allows both destinations over HTTPS only. The Worker fetches with the scheme the container used, so a plain HTTP request to the gateway would send the gateway token unencrypted. Every other request gets a 403 response, so Claude Code cannot install packages from npm or fetch web pages while it works.
To allow another destination, add it to this entrypoint. Each destination you add is another way for data to leave the sandbox. For more information, refer to Sandbox security.
Create src/sandbox.ts. The AgentSandbox Durable Object clones a repository, runs one Claude Code task at a time, and reports the task state and the repository diff:
import { Files, SandboxFileError } from "@cloudflare/sandbox";
import { DurableObject } from "cloudflare:workers";
import { z } from "zod";
const repositoryDirectory = "/workspace/repo";
const taskDirectory = "/workspace/task";
const eventsPath = `${taskDirectory}/stdout.log`;
const stderrPath = `${taskDirectory}/stderr.log`;
const exitCodePath = `${taskDirectory}/exit-code`;
const pidPath = `${taskDirectory}/pid`;
const inactivityTimeout = 30 * 60 * 1000;
const taskCheckInterval = 60 * 1000;
const taskKey = "task";
const caPath = "/etc/cloudflare/certs/cloudflare-containers-ca.crt";
// The CA alone replaces the system trust store. This works because
// Outbound intercepts every HTTPS request from the container.
const trustEnv = {
NODE_EXTRA_CA_CERTS: caPath,
GIT_SSL_CAINFO: caPath,
CURL_CA_BUNDLE: caPath,
SSL_CERT_FILE: caPath,
};
// Runs the command in its own process group. Records its process ID when
// it starts and its exit code when it ends.
const taskScript = `dir=$1; shift
setsid sh -c 'echo "$$ $(cat /proc/sys/kernel/random/boot_id)" >"$0/pid"; exec "$@"' \\
"$dir" "$@" >"$dir/stdout.log" 2>"$dir/stderr.log"
echo "$?" >"$dir/exit-code.tmp" && mv "$dir/exit-code.tmp" "$dir/exit-code"`;
// Succeeds while the process in the file runs and started in this instance.
const taskRunningScript = `read -r pid boot <"$1" &&
[ "$boot" = "$(cat /proc/sys/kernel/random/boot_id)" ] &&
kill -0 "$pid"`;
const ResultEvent = z.object({
type: z.literal("result"),
subtype: z.string(),
is_error: z.boolean(),
result: z.string().optional(),
});
export class AgentSandbox extends DurableObject {
container;
files;
setup;
constructor(ctx, env) {
super(ctx, env);
const container = ctx.container;
if (!container) {
throw new Error("The container binding is not configured");
}
this.container = container;
this.files = new Files(container);
if (container.running) {
void ctx.blockConcurrencyWhile(() =>
container.setInactivityTimeout(inactivityTimeout),
);
}
}
async cloneRepository(repository, ref) {
await this.startSandbox();
const branch = ref === undefined ? [] : ["--branch", ref];
return this.run(
[
"git",
"clone",
"--depth",
"1",
...branch,
"--",
repository,
repositoryDirectory,
],
"/workspace",
trustEnv,
);
}
startTask(prompt) {
return this.ctx.blockConcurrencyWhile(async () => {
await this.startSandbox();
if ((await this.taskStatus()).state === "running") {
return "busy";
}
await this.files.remove(taskDirectory, { recursive: true, force: true });
await this.files.mkdir(taskDirectory);
await this.container.exec(
[
"/bin/sh",
"-c",
taskScript,
"agent",
taskDirectory,
...this.agentCommand(prompt),
],
{
cwd: repositoryDirectory,
env: { ...trustEnv, ...this.agentEnv() },
stdout: "ignore",
stderr: "ignore",
},
);
this.ctx.storage.kv.put(taskKey, "started");
await this.ctx.storage.setAlarm(Date.now() + taskCheckInterval);
return "started";
});
}
async alarm() {
if (!this.container.running) {
return;
}
if ((await this.taskStatus()).state === "running") {
await this.ctx.storage.setAlarm(Date.now() + taskCheckInterval);
return;
}
this.ctx.storage.kv.delete(taskKey);
}
async readTask() {
if (!this.container.running) {
return this.ctx.storage.kv.get(taskKey) === undefined
? { state: "none" }
: { state: "lost" };
}
return this.taskStatus();
}
async readDiff() {
await this.startSandbox();
return this.run(
["/bin/sh", "-c", "git add --intent-to-add . && git diff"],
repositoryDirectory,
{},
);
}
agentCommand(prompt) {
return [
"claude",
"--print",
"--output-format",
"stream-json",
"--verbose",
"--dangerously-skip-permissions",
"--no-session-persistence",
"--model",
this.env.MODEL,
"--",
prompt,
];
}
agentEnv() {
return {
ANTHROPIC_BASE_URL: `https://gateway.ai.cloudflare.com/v1/${this.env.AI_GATEWAY_ACCOUNT_ID}/${this.env.AI_GATEWAY_ID}/anthropic`,
ANTHROPIC_API_KEY: "provided-by-worker",
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
IS_SANDBOX: "1",
};
}
async startSandbox() {
// Set up each new container, and a running container
// after this Durable Object restarts.
if (this.setup === undefined || !this.container.running) {
this.setup = this.setUpSandbox().catch((error) => {
this.setup = undefined;
throw error;
});
}
await this.setup;
}
async setUpSandbox() {
if (!this.container.running) {
this.ctx.storage.kv.delete(taskKey);
this.container.start({
image: this.container.images.agent,
instance: "standard-1",
enableInternet: false,
});
}
try {
await this.container.interceptAllOutboundHttp(this.ctx.exports.Outbound);
await this.container.interceptOutboundHttps(
"*",
this.ctx.exports.Outbound,
);
await this.container.setInactivityTimeout(inactivityTimeout);
} catch (error) {
// The next request starts a new container.
await this.container.destroy();
throw error;
}
}
async taskStatus() {
const exitCode = await this.readOptionalText(exitCodePath);
if (exitCode !== undefined) {
return this.outcome(Number.parseInt(exitCode, 10));
}
const pid = await this.readOptionalText(pidPath);
if (pid === undefined) {
// The task writes its process ID right after it starts.
return this.ctx.storage.kv.get(taskKey) === undefined
? { state: "none" }
: { state: "running" };
}
const probe = await this.run(
["/bin/sh", "-c", taskRunningScript, "probe", pidPath],
"/",
{},
);
if (probe.exitCode === 0) {
return { state: "running" };
}
// The task can finish after the first read, so read the exit code
// again.
const lateExitCode = await this.readOptionalText(exitCodePath);
if (lateExitCode !== undefined) {
return this.outcome(Number.parseInt(lateExitCode, 10));
}
return { state: "lost" };
}
async outcome(exitCode) {
let result;
const events = await this.files.readFile(eventsPath);
for await (const line of readLines(events.body)) {
const parsed = ResultEvent.safeParse(parseJson(line));
if (parsed.success) {
result = parsed.data;
}
}
if (result === undefined) {
const stderr = (await this.readOptionalText(stderrPath)) ?? "";
return {
state: "failed",
error: `claude exited with ${exitCode}: ${stderr.slice(-2000)}`,
};
}
if (result.is_error) {
return { state: "failed", error: result.result ?? result.subtype };
}
return { state: "succeeded", result: result.result ?? "" };
}
async readOptionalText(path) {
try {
return await (await this.files.readFile(path)).text();
} catch (error) {
if (SandboxFileError.is(error) && error.code === "ENOENT") {
return undefined;
}
throw error;
}
}
async run(command, cwd, env) {
const process = await this.container.exec(command, { cwd, env });
const output = await process.output();
const decoder = new TextDecoder();
return {
exitCode: output.exitCode,
stdout: decoder.decode(output.stdout),
stderr: decoder.decode(output.stderr),
};
}
}
function parseJson(line) {
try {
return JSON.parse(line);
} catch {
return undefined;
}
}
async function* readLines(body) {
if (body === null) {
return;
}
const decoder = new TextDecoder();
let buffered = "";
for await (const chunk of body) {
buffered += decoder.decode(chunk, { stream: true });
const lines = buffered.split("\n");
buffered = lines.pop() ?? "";
yield* lines;
}
buffered += decoder.decode();
if (buffered !== "") {
yield buffered;
}
}import { Files, SandboxFileError } from "@cloudflare/sandbox";
import { DurableObject } from "cloudflare:workers";
import { z } from "zod";
const repositoryDirectory = "/workspace/repo";
const taskDirectory = "/workspace/task";
const eventsPath = `${taskDirectory}/stdout.log`;
const stderrPath = `${taskDirectory}/stderr.log`;
const exitCodePath = `${taskDirectory}/exit-code`;
const pidPath = `${taskDirectory}/pid`;
const inactivityTimeout = 30 * 60 * 1000;
const taskCheckInterval = 60 * 1000;
const taskKey = "task";
const caPath = "/etc/cloudflare/certs/cloudflare-containers-ca.crt";
// The CA alone replaces the system trust store. This works because
// Outbound intercepts every HTTPS request from the container.
const trustEnv = {
NODE_EXTRA_CA_CERTS: caPath,
GIT_SSL_CAINFO: caPath,
CURL_CA_BUNDLE: caPath,
SSL_CERT_FILE: caPath,
};
// Runs the command in its own process group. Records its process ID when
// it starts and its exit code when it ends.
const taskScript = `dir=$1; shift
setsid sh -c 'echo "$$ $(cat /proc/sys/kernel/random/boot_id)" >"$0/pid"; exec "$@"' \\
"$dir" "$@" >"$dir/stdout.log" 2>"$dir/stderr.log"
echo "$?" >"$dir/exit-code.tmp" && mv "$dir/exit-code.tmp" "$dir/exit-code"`;
// Succeeds while the process in the file runs and started in this instance.
const taskRunningScript = `read -r pid boot <"$1" &&
[ "$boot" = "$(cat /proc/sys/kernel/random/boot_id)" ] &&
kill -0 "$pid"`;
const ResultEvent = z.object({
type: z.literal("result"),
subtype: z.string(),
is_error: z.boolean(),
result: z.string().optional(),
});
export type TaskStatus =
| { state: "none" }
| { state: "running" }
| { state: "lost" }
| { state: "succeeded"; result: string }
| { state: "failed"; error: string };
export type CommandResult = {
exitCode: number;
stdout: string;
stderr: string;
};
export class AgentSandbox extends DurableObject<Env> {
private readonly container: Container;
private readonly files: Files;
private setup: Promise<void> | undefined;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
const container = ctx.container;
if (!container) {
throw new Error("The container binding is not configured");
}
this.container = container;
this.files = new Files(container);
if (container.running) {
void ctx.blockConcurrencyWhile(() =>
container.setInactivityTimeout(inactivityTimeout),
);
}
}
async cloneRepository(
repository: string,
ref: string | undefined,
): Promise<CommandResult> {
await this.startSandbox();
const branch = ref === undefined ? [] : ["--branch", ref];
return this.run(
[
"git",
"clone",
"--depth",
"1",
...branch,
"--",
repository,
repositoryDirectory,
],
"/workspace",
trustEnv,
);
}
startTask(prompt: string): Promise<"started" | "busy"> {
return this.ctx.blockConcurrencyWhile(async () => {
await this.startSandbox();
if ((await this.taskStatus()).state === "running") {
return "busy";
}
await this.files.remove(taskDirectory, { recursive: true, force: true });
await this.files.mkdir(taskDirectory);
await this.container.exec(
[
"/bin/sh",
"-c",
taskScript,
"agent",
taskDirectory,
...this.agentCommand(prompt),
],
{
cwd: repositoryDirectory,
env: { ...trustEnv, ...this.agentEnv() },
stdout: "ignore",
stderr: "ignore",
},
);
this.ctx.storage.kv.put(taskKey, "started");
await this.ctx.storage.setAlarm(Date.now() + taskCheckInterval);
return "started";
});
}
async alarm(): Promise<void> {
if (!this.container.running) {
return;
}
if ((await this.taskStatus()).state === "running") {
await this.ctx.storage.setAlarm(Date.now() + taskCheckInterval);
return;
}
this.ctx.storage.kv.delete(taskKey);
}
async readTask(): Promise<TaskStatus> {
if (!this.container.running) {
return this.ctx.storage.kv.get(taskKey) === undefined
? { state: "none" }
: { state: "lost" };
}
return this.taskStatus();
}
async readDiff(): Promise<CommandResult> {
await this.startSandbox();
return this.run(
["/bin/sh", "-c", "git add --intent-to-add . && git diff"],
repositoryDirectory,
{},
);
}
private agentCommand(prompt: string): string[] {
return [
"claude",
"--print",
"--output-format",
"stream-json",
"--verbose",
"--dangerously-skip-permissions",
"--no-session-persistence",
"--model",
this.env.MODEL,
"--",
prompt,
];
}
private agentEnv(): Record<string, string> {
return {
ANTHROPIC_BASE_URL: `https://gateway.ai.cloudflare.com/v1/${this.env.AI_GATEWAY_ACCOUNT_ID}/${this.env.AI_GATEWAY_ID}/anthropic`,
ANTHROPIC_API_KEY: "provided-by-worker",
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
IS_SANDBOX: "1",
};
}
private async startSandbox(): Promise<void> {
// Set up each new container, and a running container
// after this Durable Object restarts.
if (this.setup === undefined || !this.container.running) {
this.setup = this.setUpSandbox().catch((error) => {
this.setup = undefined;
throw error;
});
}
await this.setup;
}
private async setUpSandbox(): Promise<void> {
if (!this.container.running) {
this.ctx.storage.kv.delete(taskKey);
this.container.start({
image: this.container.images.agent,
instance: "standard-1",
enableInternet: false,
});
}
try {
await this.container.interceptAllOutboundHttp(this.ctx.exports.Outbound);
await this.container.interceptOutboundHttps(
"*",
this.ctx.exports.Outbound,
);
await this.container.setInactivityTimeout(inactivityTimeout);
} catch (error) {
// The next request starts a new container.
await this.container.destroy();
throw error;
}
}
private async taskStatus(): Promise<TaskStatus> {
const exitCode = await this.readOptionalText(exitCodePath);
if (exitCode !== undefined) {
return this.outcome(Number.parseInt(exitCode, 10));
}
const pid = await this.readOptionalText(pidPath);
if (pid === undefined) {
// The task writes its process ID right after it starts.
return this.ctx.storage.kv.get(taskKey) === undefined
? { state: "none" }
: { state: "running" };
}
const probe = await this.run(
["/bin/sh", "-c", taskRunningScript, "probe", pidPath],
"/",
{},
);
if (probe.exitCode === 0) {
return { state: "running" };
}
// The task can finish after the first read, so read the exit code
// again.
const lateExitCode = await this.readOptionalText(exitCodePath);
if (lateExitCode !== undefined) {
return this.outcome(Number.parseInt(lateExitCode, 10));
}
return { state: "lost" };
}
private async outcome(exitCode: number): Promise<TaskStatus> {
let result: z.infer<typeof ResultEvent> | undefined;
const events = await this.files.readFile(eventsPath);
for await (const line of readLines(events.body)) {
const parsed = ResultEvent.safeParse(parseJson(line));
if (parsed.success) {
result = parsed.data;
}
}
if (result === undefined) {
const stderr = (await this.readOptionalText(stderrPath)) ?? "";
return {
state: "failed",
error: `claude exited with ${exitCode}: ${stderr.slice(-2000)}`,
};
}
if (result.is_error) {
return { state: "failed", error: result.result ?? result.subtype };
}
return { state: "succeeded", result: result.result ?? "" };
}
private async readOptionalText(path: string): Promise<string | undefined> {
try {
return await (await this.files.readFile(path)).text();
} catch (error) {
if (SandboxFileError.is(error) && error.code === "ENOENT") {
return undefined;
}
throw error;
}
}
private async run(
command: string[],
cwd: string,
env: Record<string, string>,
): Promise<CommandResult> {
const process = await this.container.exec(command, { cwd, env });
const output = await process.output();
const decoder = new TextDecoder();
return {
exitCode: output.exitCode,
stdout: decoder.decode(output.stdout),
stderr: decoder.decode(output.stderr),
};
}
}
function parseJson(line: string): unknown {
try {
return JSON.parse(line);
} catch {
return undefined;
}
}
async function* readLines(
body: ReadableStream<Uint8Array> | null,
): AsyncGenerator<string> {
if (body === null) {
return;
}
const decoder = new TextDecoder();
let buffered = "";
for await (const chunk of body) {
buffered += decoder.decode(chunk, { stream: true });
const lines = buffered.split("\n");
buffered = lines.pop() ?? "";
yield* lines;
}
buffered += decoder.decode();
if (buffered !== "") {
yield buffered;
}
}Only ResultEvent, agentCommand(), agentEnv(), and outcome() are specific to Claude Code. The other pages in Coding agents replace those parts to run a different agent in the same Worker.
startSandbox() starts the container from the image Wrangler built, with no direct Internet access. In this example, the container uses the standard-1 instance type. Choose a size that fits your agent and your repositories.
After each start, startSandbox() routes HTTP and HTTPS requests from the container on ports 80 and 443 to the Outbound entrypoint. The interception ends with the container, so startSandbox() registers it on every start. For more information, refer to interceptOutboundHttps().
running is true as soon as start() returns, before the interception is registered. A request that arrives during setup waits for the same setUpSandbox() call, so no command runs before the interception is in place. If a setup step fails, setUpSandbox() stops the container, and the next request starts a new one.
A deploy restarts the Durable Object and can stop setUpSandbox() after start(). The restarted Durable Object runs setUpSandbox() again for the container that is already running. Registering an interception again replaces the earlier one, so the container gets its interception before the next command.
Containers re-signs HTTPS traffic from the container with a Cloudflare certificate authority, so the Outbound entrypoint can read it. The trustEnv variables point Git, Node.js, and other tools at that certificate. The Durable Object passes them to every command that makes HTTPS requests. SSL_CERT_FILE, CURL_CA_BUNDLE, and GIT_SSL_CAINFO replace the system trust store with that one certificate, which works only because the Outbound entrypoint receives every HTTPS request. If you copy them to a class that intercepts some hostnames only, add the certificate to the system trust store instead. For more information, refer to Trust the CA certificate.
A Claude Code task can run for several minutes, longer than a request should wait. startTask() starts the task and returns. The client then checks the task state with later requests.
Claude Code runs without its own permission prompts, because the sandbox limits what its commands can reach. The command sets these options:
--printruns Claude Code without an interactive terminal.--output-format stream-json --verbosewrites one JSON event per line, including a finalresultevent.--dangerously-skip-permissionslets Claude Code run commands and edit files without asking. Claude Code requiresIS_SANDBOX=1to allow this asroot.ANTHROPIC_BASE_URLsends model requests to the Anthropic endpoint of your gateway.ANTHROPIC_API_KEYis a placeholder. Claude Code does not start without an API key, and theOutboundentrypoint removes the key from each request.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFICturns off update checks, telemetry, and error reporting, so Claude Code calls only your gateway.
If the repository has a CLAUDE.md file, Claude Code reads it, so the instructions in that file apply to the task.
The process writes its output to files instead of sending it back to the Durable Object. A process with piped output receives SIGPIPE after the request that started it ends, which stops Claude Code in the middle of its task.
taskScript is the RUN script from Run background processes. setsid starts Claude Code in its own process group, and the script records the process ID of Claude Code when it starts. When Claude Code exits, the script writes the exit code to a temporary file and then renames it, so the Durable Object never reads a partial exit code.
The task files live in /workspace/task, outside the repository, so they do not appear in the diff.
When two requests start a task at the same time, blockConcurrencyWhile() runs them one after the other. The second request sees the running task and returns busy.
A running process does not keep the container running, so startTask() schedules an alarm that checks the task every minute until it ends. Each check keeps the container running. For more information, refer to Sandbox lifetime.
The task has one of these states:
runningwhile the Claude Code process exists.succeededwith the final reply from Claude Code.failedwith an error message.lostwhen the process or its container stopped before Claude Code recorded an exit code.nonewhen no task has run in this container.
Claude Code exits successfully even when a model request fails. The outcome comes from the is_error field of the final result event instead of the exit code. The events file comes from the sandbox, so the Durable Object validates each event with Zod before it reads it. When there is no result event, the task fails with the end of the standard error output from Claude Code.
While no exit code exists, the Durable Object runs kill -0 to check whether the process still exists. The command runs through sh, because kill is a shell built-in and the slim image has no separate kill binary. If the process is gone, the Durable Object reads the exit code again, because the task can finish between the two reads. Only a process that ended without an exit code is lost.
The process ID file also records the boot ID, which changes in every instance. If you restore the workspace from a snapshot, the file comes back, and the new instance reuses the same process IDs. The check ignores a process ID from another instance, so a task that was running when the snapshot was taken is lost.
readDiff() marks new files with git add --intent-to-add so that git diff includes them.
Replace src/index.ts with the following Worker. It exports both classes and maps each route to a method on the sandbox named in the URL:
import { z } from "zod";
export { Outbound } from "./outbound";
export { AgentSandbox } from "./sandbox";
const sandboxName = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
// Linux rejects a command argument of 128 KiB or more, including its NUL byte.
const maxPromptBytes = 128 * 1024 - 1;
const RepositoryRequest = z.object({
url: z.url({ protocol: /^https$/, hostname: /^github\.com$/ }),
ref: z.string().min(1).optional(),
});
export default {
async fetch(request, env) {
const url = new URL(request.url);
const match = /^\/sandboxes\/([^/]+)\/(repository|task|diff)$/.exec(
url.pathname,
);
if (!match) {
return new Response("Not found", { status: 404 });
}
const [, name, resource] = match;
if (!sandboxName.test(name)) {
return new Response(
"The sandbox name must be 1-63 lowercase letters, digits, or hyphens, with no hyphen at either end",
{ status: 400 },
);
}
const sandbox = env.SANDBOX.getByName(name);
try {
if (resource === "repository" && request.method === "POST") {
const body = RepositoryRequest.safeParse(
await request.json().catch(() => null),
);
if (!body.success) {
return new Response(
'Send {"url": "https://github.com/OWNER/REPOSITORY"}',
{ status: 400 },
);
}
const result = await sandbox.cloneRepository(
body.data.url,
body.data.ref,
);
return Response.json(result, {
status: result.exitCode === 0 ? 200 : 502,
});
}
if (resource === "task" && request.method === "POST") {
const prompt = await request.text();
if (prompt.trim() === "") {
return new Response("Send a prompt", { status: 400 });
}
if (new TextEncoder().encode(prompt).byteLength > maxPromptBytes) {
return new Response("Send a prompt smaller than 128 KiB", {
status: 413,
});
}
if ((await sandbox.startTask(prompt)) === "busy") {
return new Response("A task is already running", { status: 409 });
}
return Response.json({ state: "running" }, { status: 202 });
}
if (resource === "task" && request.method === "GET") {
return Response.json(await sandbox.readTask());
}
if (resource === "diff" && request.method === "GET") {
const result = await sandbox.readDiff();
if (result.exitCode !== 0) {
return Response.json(result, { status: 502 });
}
return new Response(result.stdout, {
headers: { "content-type": "text/plain" },
});
}
return new Response("Method not allowed", { status: 405 });
} catch (error) {
console.error("Sandbox request failed", error);
return new Response("Sandbox request failed", { status: 500 });
}
},
};import { z } from "zod";
export { Outbound } from "./outbound";
export { AgentSandbox } from "./sandbox";
const sandboxName = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
// Linux rejects a command argument of 128 KiB or more, including its NUL byte.
const maxPromptBytes = 128 * 1024 - 1;
const RepositoryRequest = z.object({
url: z.url({ protocol: /^https$/, hostname: /^github\.com$/ }),
ref: z.string().min(1).optional(),
});
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const match = /^\/sandboxes\/([^/]+)\/(repository|task|diff)$/.exec(
url.pathname,
);
if (!match) {
return new Response("Not found", { status: 404 });
}
const [, name, resource] = match;
if (!sandboxName.test(name)) {
return new Response(
"The sandbox name must be 1-63 lowercase letters, digits, or hyphens, with no hyphen at either end",
{ status: 400 },
);
}
const sandbox = env.SANDBOX.getByName(name);
try {
if (resource === "repository" && request.method === "POST") {
const body = RepositoryRequest.safeParse(
await request.json().catch(() => null),
);
if (!body.success) {
return new Response(
'Send {"url": "https://github.com/OWNER/REPOSITORY"}',
{ status: 400 },
);
}
const result = await sandbox.cloneRepository(
body.data.url,
body.data.ref,
);
return Response.json(result, {
status: result.exitCode === 0 ? 200 : 502,
});
}
if (resource === "task" && request.method === "POST") {
const prompt = await request.text();
if (prompt.trim() === "") {
return new Response("Send a prompt", { status: 400 });
}
if (new TextEncoder().encode(prompt).byteLength > maxPromptBytes) {
return new Response("Send a prompt smaller than 128 KiB", {
status: 413,
});
}
if ((await sandbox.startTask(prompt)) === "busy") {
return new Response("A task is already running", { status: 409 });
}
return Response.json({ state: "running" }, { status: 202 });
}
if (resource === "task" && request.method === "GET") {
return Response.json(await sandbox.readTask());
}
if (resource === "diff" && request.method === "GET") {
const result = await sandbox.readDiff();
if (result.exitCode !== 0) {
return Response.json(result, { status: 502 });
}
return new Response(result.stdout, {
headers: { "content-type": "text/plain" },
});
}
return new Response("Method not allowed", { status: 405 });
} catch (error) {
console.error("Sandbox request failed", error);
return new Response("Sandbox request failed", { status: 500 });
}
},
} satisfies ExportedHandler<Env>;The Worker accepts only https://github.com/ repository URLs, because the Outbound entrypoint blocks every other Git server. The routes are:
POST /sandboxes/<NAME>/repositoryclones a repository into the sandbox.POST /sandboxes/<NAME>/taskstarts Claude Code with the request body as its prompt. The prompt becomes one argument of theclaudecommand, so a prompt of 128 KiB or more gets a413response.GET /sandboxes/<NAME>/taskreturns the task state.GET /sandboxes/<NAME>/diffreturns the changes in the repository.
-
Create a file named
.secrets.jsonwith your gateway token:.secrets.jsonjson { "AI_GATEWAY_TOKEN": "<AI_GATEWAY_TOKEN>" } -
Deploy your Worker with the secret:
npx wrangler deploy --secrets-file .secrets.jsonyarn wrangler deploy --secrets-file .secrets.jsonpnpm wrangler deploy --secrets-file .secrets.jsonWrangler builds the image with Docker, pushes it to your account, and uploads the Worker with the
AI_GATEWAY_TOKENsecret. -
Delete the secrets file:
rm .secrets.json -
Save the
workers.devURL that Wrangler prints in a shell variable:WORKER_URL=https://sandbox-coding-agent.<YOUR_SUBDOMAIN>.workers.dev
-
Clone GitHub's
Hello-Worldrepository into a sandbox namedagent-1:curl "$WORKER_URL/sandboxes/agent-1/repository" \ --json '{"url": "https://github.com/octocat/Hello-World"}'The first request starts the container, so it can take about a minute. Git writes its progress to standard error:
{ "exitCode": 0, "stdout": "", "stderr": "Cloning into '/workspace/repo'...\n" } -
Start a task:
curl "$WORKER_URL/sandboxes/agent-1/task" \ --data "Add a file NOTES.md with a one-sentence summary of this repository. Do not commit."The Worker responds with
202and the task state:{ "state": "running" } -
Check the task until its state is
succeededorfailed:curl "$WORKER_URL/sandboxes/agent-1/task"A finished task includes the final reply from Claude Code:
{ "state": "succeeded", "result": "Created `NOTES.md` with a one-sentence summary. Left uncommitted as requested." }The reply differs on each run. This task takes a few seconds, and larger tasks take minutes. The alarm keeps the sandbox running until Claude Code exits, so you do not need to keep checking.
-
Read the changes:
curl "$WORKER_URL/sandboxes/agent-1/diff"The response is a
git diffof the repository. The new file appears becausereadDiff()marks it with--intent-to-add:diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 0000000..95da11e --- /dev/null +++ b/NOTES.md @@ -0,0 +1 @@ +This repository is a minimal test/example repo containing only a "Hello World!" README file.
If the model request fails, the task state is failed and error contains the message from AI Gateway:
Failed to authenticate. API Error: 401 Unauthorizedmeans the gateway rejected the token. Check that the token belongs to the account and gateway inwrangler.jsonc. Claude Code retries a failing model request 10 times, so this error takes about three minutes to appear.API Error: 402 Insufficient wholesale creditsmeans the gateway uses Unified Billing and the account has no credits. Add credits, or store an Anthropic API key in the gateway.
- Run another agent in the same Worker. Refer to Codex, OpenCode, or Pi.
- Deploy a finished runner for each agent. Refer to the coding agents example ↗︎, which deploys one Worker for each agent instead of switching one Worker.
- Keep the files of a sandbox after its container stops. Refer to Save and restore a sandbox with snapshots.
- Clone private repositories with a token that stays in your Worker. Refer to Clone a private repository.
- Decide what one sandbox should hold. Refer to Sandbox security.
- Route Claude Code through AI Gateway from other environments. Refer to Claude Code in AI Gateway.