Build a coding agent runner

Last updated Oct 22, 2026 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.

Prerequisites

  1. Sign up for a Cloudflare account ↗︎.
  2. Install Node.js ↗︎.

<details>

<summary>

Node.js version manager

</summary>

Use <a href="https://viteplus.dev/guide/env">Vite+ (<code>vp env</code>) ↗︎</a> to install and manage Node.js versions.

For supported Node.js versions, refer to the <a href="https://developers.cloudflare.com/workers/wrangler/install-and-update/#install-wrangler">Wrangler system requirements</a>.

</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 ↗︎. 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:

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.308

COPY --from=docker.io/cloudflare/sandbox:1.0.1 /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-27",
    "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-27"
   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

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

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.

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("No container is 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 HEAD"], 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; }