---
description: Build a Worker that clones a GitHub repository into a Linux sandbox, runs Claude Code on a task in the background, and returns its changes as a diff.
title: Build a coding agent runner
image: https://developers.cloudflare.com/sandbox/get-started/build-a-coding-agent-runner/og.png?v=db44ca7232ae4c09
---

[Skip to content](#main-content)

> Documentation Index  
> Fetch the complete documentation index at: https://developers.cloudflare.com/sandbox/llms.txt  
> Use this file to discover all available pages before exploring further.

# Build a coding agent runner

Last updated Sep 30, 2026|Copy as Markdown| [View as Markdown](https://developers.cloudflare.com/sandbox/get-started/build-a-coding-agent-runner/index.md)| [Agent setup](https://developers.cloudflare.com/agent-setup/)

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 ↗︎](https://code.claude.com/docs/en/overview). The pages in [Coding agents](https://developers.cloudflare.com/sandbox/coding-agents/) switch it to other agents.

Claude Code calls Anthropic models through [AI Gateway](https://developers.cloudflare.com/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
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.

## Prerequisites

1. Sign up for a [Cloudflare account ↗︎](https://dash.cloudflare.com/sign-up/workers-and-pages).
2. Install [`Node.js` ↗︎](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm).

<details>

<summary>

Node.js version manager

</summary>

Use a Node version manager like <a href="https://volta.sh/">Volta ↗︎</a> or <a href="https://github.com/nvm-sh/nvm">nvm ↗︎</a> to avoid permission issues and change Node.js versions. <a href="https://developers.cloudflare.com/workers/wrangler/install-and-update/">Wrangler</a>, discussed later in this guide, requires a Node version of <code>16.17.0</code> or later.

</details>

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 ↗︎](https://docs.docker.com/desktop/). Other tools like [Colima ↗︎](https://github.com/abiosoft/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](https://developers.cloudflare.com/ai-gateway/configuration/authentication/) and a gateway token with the **Run** permission.
- Anthropic credentials in AI Gateway, either [Unified Billing](https://developers.cloudflare.com/ai-gateway/features/unified-billing/) credits or an Anthropic API key stored as a [provider key](https://developers.cloudflare.com/ai-gateway/configuration/bring-your-own-keys/).
- Your [Cloudflare account ID](https://developers.cloudflare.com/fundamentals/account/find-account-and-zone-ids/).

## 1. Create the project

1. Create a Worker project:npmyarnpnpm

   ```
   npm create cloudflare@latest -- sandbox-coding-agent --category=hello-world --type=hello-world --lang=ts --no-deploy --no-git --no-agents
   ```

   ```
   yarn create cloudflare sandbox-coding-agent --category=hello-world --type=hello-world --lang=ts --no-deploy --no-git --no-agents
   ```

   ```
   pnpm create cloudflare@latest sandbox-coding-agent --category=hello-world --type=hello-world --lang=ts --no-deploy --no-git --no-agents
   ```


2. Change into the project directory:

   ```sh
   cd sandbox-coding-agent
   ```


3. Install the [`@cloudflare/sandbox`](https://developers.cloudflare.com/sandbox/reference/) package and [Zod ↗︎](https://zod.dev/):npmyarnpnpmbun

   ```
   npm i @cloudflare/sandbox zod
   ```

   ```
   yarn add @cloudflare/sandbox zod
   ```

   ```
   pnpm add @cloudflare/sandbox zod
   ```

   ```
   bun add @cloudflare/sandbox zod
   ```


4. Create a `Dockerfile` in your project root:

   *Dockerfiledockerfile*

   

   ```dockerfile
   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/sandbox` tag that matches the `@cloudflare/sandbox` version you installed.
5. Replace `wrangler.jsonc`. Replace `<ACCOUNT_ID>` with your account ID, and `default` with your gateway ID if it is different:

   ```jsonc
   {
   	"$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-05",
   	"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",
   		},
   	},
   }
   ```

   ```toml
   "$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-05"
   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 `AgentSandbox` Durable Object with its own container. Wrangler builds the `Dockerfile` when you deploy, and the Durable Object starts the built image as `this.ctx.container.images.agent`. `MODEL` is an Anthropic model ID that your gateway can serve. `secrets.required` makes `wrangler deploy` fail if the gateway token is missing.
6. Generate types for the bindings, variables, and secret:npmyarnpnpm

   ```
   npx wrangler types
   ```

   ```
   yarn wrangler types
   ```

   ```
   pnpm 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

## 2. Allow only the gateway and GitHub

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:

*src/outbound.jsjs*

```js
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 },
		);
	}
}
```

*src/outbound.tsts*

```ts
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](https://developers.cloudflare.com/sandbox/concepts/security/#every-opening-is-also-a-way-out).

## 3. Run Claude Code in the sandbox

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:

*src/sandbox.jsjs*

```js
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;
	}
}
```

*src/sandbox.tsts*

```ts
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](https://developers.cloudflare.com/sandbox/coding-agents/) replace those parts to run a different agent in the same Worker.

### Start the sandbox

`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](https://developers.cloudflare.com/containers/platform/limits/#instance-types). 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()`](https://developers.cloudflare.com/containers/api/durable-object-container/#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](https://developers.cloudflare.com/containers/configuration/outbound-traffic/#trust-the-ca-certificate).

### Run Claude Code in the background

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:

- `--print` runs Claude Code without an interactive terminal.
- `--output-format stream-json --verbose` writes one JSON event per line, including a final `result` event.
- `--dangerously-skip-permissions` lets Claude Code run commands and edit files without asking. Claude Code requires `IS_SANDBOX=1` to allow this as `root`.
- `ANTHROPIC_BASE_URL` sends model requests to the Anthropic endpoint of your gateway.
- `ANTHROPIC_API_KEY` is a placeholder. Claude Code does not start without an API key, and the `Outbound` entrypoint removes the key from each request.
- `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` turns 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](https://developers.cloudflare.com/sandbox/commands/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`.

### Keep the sandbox running

A running process does not keep the container running, so `startTask()` schedules an [alarm](https://developers.cloudflare.com/durable-objects/api/alarms/) that checks the task every minute until it ends. Each check keeps the container running. For more information, refer to [Sandbox lifetime](https://developers.cloudflare.com/sandbox/concepts/lifetime/).

### Read the outcome

The task has one of these states:

- `running` while the Claude Code process exists.
- `succeeded` with the final reply from Claude Code.
- `failed` with an error message.
- `lost` when the process or its container stopped before Claude Code recorded an exit code.
- `none` when 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.

## 4. Route requests

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:

*src/index.jsjs*

```js
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 });
		}
	},
};
```

*src/index.tsts*

```ts
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>/repository` clones a repository into the sandbox.
- `POST /sandboxes/<NAME>/task` starts Claude Code with the request body as its prompt. The prompt becomes one argument of the `claude` command, so a prompt of 128 KiB or more gets a `413` response.
- `GET /sandboxes/<NAME>/task` returns the task state.
- `GET /sandboxes/<NAME>/diff` returns the changes in the repository.

## 5. Deploy

Caution

The Worker does not authenticate requests. Anyone with the URL can run Claude Code and spend your AI Gateway credits. Authenticate callers before you share the URL. For more information, refer to [Sandbox security](https://developers.cloudflare.com/sandbox/concepts/security/#the-sandbox-name-decides-what-a-request-reaches).

1. Create a file named `.secrets.json` with your gateway token:

   *.secrets.jsonjson*

   

   ```json
   { "AI_GATEWAY_TOKEN": "<AI_GATEWAY_TOKEN>" }
   ```


2. Deploy your Worker with the secret:npmyarnpnpm

   ```
   npx wrangler deploy --secrets-file .secrets.json
   ```

   ```
   yarn wrangler deploy --secrets-file .secrets.json
   ```

   ```
   pnpm wrangler deploy --secrets-file .secrets.json
   ```

   Wrangler builds the image with Docker, pushes it to your account, and uploads the Worker with the `AI_GATEWAY_TOKEN` secret.
3. Delete the secrets file:

   ```sh
   rm .secrets.json
   ```


4. Save the `workers.dev` URL that Wrangler prints in a shell variable:

   ```sh
   WORKER_URL=https://sandbox-coding-agent.<YOUR_SUBDOMAIN>.workers.dev
   ```



## 6. Run a task

1. Clone GitHub's `Hello-World` repository into a sandbox named `agent-1`:

   ```sh
   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:

   ```json
   {
   	"exitCode": 0,
   	"stdout": "",
   	"stderr": "Cloning into '/workspace/repo'...\n"
   }
   ```


2. Start a task:

   ```sh
   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 `202` and the task state:

   ```json
   { "state": "running" }
   ```


3. Check the task until its state is `succeeded` or `failed`:

   ```sh
   curl "$WORKER_URL/sandboxes/agent-1/task"
   ```

   A finished task includes the final reply from Claude Code:

   ```json
   {
   	"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.
4. Read the changes:

   ```sh
   curl "$WORKER_URL/sandboxes/agent-1/diff"
   ```

   The response is a `git diff` of the repository. The new file appears because `readDiff()` marks it with `--intent-to-add`:

   ```diff
   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 Unauthorized` means the gateway rejected the token. Check that the token belongs to the account and gateway in `wrangler.jsonc`. Claude Code retries a failing model request 10 times, so this error takes about three minutes to appear.
- `API Error: 402 Insufficient wholesale credits` means the gateway uses Unified Billing and the account has no credits. Add credits, or store an Anthropic API key in the gateway.

## Next steps

- Run another agent in the same Worker. Refer to [Codex](https://developers.cloudflare.com/sandbox/coding-agents/codex/), [OpenCode](https://developers.cloudflare.com/sandbox/coding-agents/opencode/), or [Pi](https://developers.cloudflare.com/sandbox/coding-agents/pi/).
- Deploy a finished runner for each agent. Refer to the [coding agents example ↗︎](https://github.com/cloudflare/sandbox-sdk/tree/main/examples/coding-agents), 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](https://developers.cloudflare.com/sandbox/files/save-and-restore-a-workspace/).
- Clone private repositories with a token that stays in your Worker. Refer to [Clone a private repository](https://developers.cloudflare.com/sandbox/network/clone-a-private-repository/).
- Decide what one sandbox should hold. Refer to [Sandbox security](https://developers.cloudflare.com/sandbox/concepts/security/).
- Route Claude Code through AI Gateway from other environments. Refer to [Claude Code in AI Gateway](https://developers.cloudflare.com/ai-gateway/integrations/coding-agents/claude-code/).

Was this helpful?

YesNo

## On this page

[![](https://developers.cloudflare.com/_astro/logo.te5VL_aD.svg)Docs](https://developers.cloudflare.com/)

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/sandbox/get-started/build-a-coding-agent-runner/#page","headline":"Build a coding agent runner","description":"Build a Worker that clones a GitHub repository into a Linux sandbox, runs Claude Code on a task in the background, and returns its changes as a diff.","url":"https://developers.cloudflare.com/sandbox/get-started/build-a-coding-agent-runner/","inLanguage":"en","image":"https://developers.cloudflare.com/sandbox/get-started/build-a-coding-agent-runner/og.png?v=db44ca7232ae4c09","dateModified":"2026-09-30","publisher":{"@type":"Organization","name":"Cloudflare","description":"One platform for your apps, agents, and workforce. Build, secure, and scale without managing infrastructure","url":"https://www.cloudflare.com/","sameAs":["https://github.com/cloudflare","https://www.linkedin.com/company/cloudflare","https://x.com/cloudflare"],"logo":{"@type":"ImageObject","url":"https://developers.cloudflare.com/logo.svg"},"address":{"@type":"PostalAddress","streetAddress":"101 Townsend St","addressLocality":"San Francisco","addressRegion":"CA","postalCode":"94107","addressCountry":"US"},"contactPoint":[{"@type":"ContactPoint","contactType":"Customer Support","url":"https://support.cloudflare.com/","availableLanguage":["English"]},{"@type":"ContactPoint","contactType":"Sales","url":"https://www.cloudflare.com/contact/","availableLanguage":["English"]}]},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
```
