Build a coding agent runner·续2

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 that checks the task every minute until it ends. Each check keeps the container running. For more information, refer to Sandbox 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 the diff includes them. git diff HEAD compares the files with the checked-out commit, so the diff also includes changes that the agent staged.

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

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

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.

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

.secrets.jsonjson

   { "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:

   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

[![](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI2NiIgaGVpZ2h0PSIzMCIgZmlsbD0ibm9uZSIgdmlld0JveD0iMCAwIDY2IDMwIj48ZyBjbGlwLXBhdGg9InVybCgjY2xpcDBfNzQ2XzIpIj48cGF0aCBmaWxsPSIjZmY5OTEwIiBkPSJNNTMuMTIgMTMuMTI1YzYuOTk3IDAgMTIuNjY4IDUuNzA4IDEyLjY2OCAxMi43NSAwIDEuMjU1LS4xOCAyLjQ2OC0uNTE2IDMuNjE0YS43Mi43MiAwIDAgMS0uNjk0LjUxMUg0Ni45NTNhLjM3NS4zNzUgMCAwIDEtLjM0OC0uNTFsLjMzNy0uODgyYzEuMDc2LTIuODMzIDQuMTQ2LTUuMTU0IDYuOTg3LTUuMjlsNS4zODQtLjI3N2EuNTQzLjU0MyAwIDAgMCAuNTE0LS41NDQuNTQzLjU0MyAwIDAgMC0uNTEtLjU0NGwtNS4wNTYtLjI5OWMtMi43MTgtLjE0LTQuMDQ2LTIuNTYtMy4zMTUtNS4wODFsLjg0Mi0yLjkwMmEuNzIuNzIgMCAwIDEgLjY0Ni0uNTI3cS4zNC0uMDIuNjg2LS4wMTkiLz48cGF0aCBmaWxsPSIjZmY1ZjA4IiBkPSJNMzUuNjA5IDBjNy4yOTUgMCAxMy40MjQgNS4wMjUgMTUuMTU3IDExLjgyNC4wNzMuMjg1LjA1LjU4Ni0uMDU0Ljg2MmwtMS4zODggMy42NzdjLTEuMDc2IDIuODMzLTMuODQyIDUuMjktNi45NyA1LjI5bC0yNC4xMDUuMjc4YS41MzMuNTMzIDAgMCAwLS41MjUuNTM1YzAgLjI5My4yMzQuNTMxLjUyNC41MzVsMjMuNzY1LjMxNmMyLjcyNCAwIDQuMDQ2IDIuNTYgMy4zMTYgNS4wODFsLS4zMDcgMS4wNjFhLjc1Ljc1IDAgMCAxLS43MTYuNTQxSC45NjRhLjcxLjcxIDAgMCAxLS43MTgtLjY0NiAxMSAxMSAwIDAgMS0uMDM0LS44NTRjMC01LjQ3NiA0LjE2Ni05Ljk3MyA5LjQ4Mi0xMC40NTZhNy4yIDcuMiAwIDAgMS0uMTY3LTEuNTQ0YzAtMy45MzUgMy4xNy03LjEyNSA3LjA4LTcuMTI1IDEuNTU1IDAgMi45OTQuNTA2IDQuMTYyIDEuMzYyQzIyLjg0OSA0LjQ5NyAyOC43MDcgMCAzNS42MDkgMCIvPjxwYXRoIGZpbGw9IiNmZjk5MTAiIGQ9Ik01My4xMiAxMy4xMjVjNi45OTcgMCAxMi42NjggNS43MDggMTIuNjY4IDEyLjc1IDAgMS4yNTUtLjE4IDIuNDY4LS41MTYgMy42MTRhLjcyLjcyIDAgMCAxLS42OTQuNTExSDQ2Ljk1M2EuMzc1LjM3NSAwIDAgMS0uMzQ4LS41MWwuMzM3LS44ODJjMS4wNzYtMi44MzMgNC4xNDYtNS4xNTQgNi45ODctNS4yOWw1LjM4NC0uMjc3YS41NDMuNTQzIDAgMCAwIC41MTQtLjU0NC41NDMuNTQzIDAgMCAwLS41MS0uNTQ0bC01LjA1Ni0uMjk5Yy0yLjcxOC0uMTQtNC4wNDYtMi41Ni0zLjMxNS01LjA4MWwuODQyLTIuOTAyYS43Mi43MiAwIDAgMSAuNjQ2LS41MjdxLjM0LS4wMi42ODYtLjAxOSIvPjxwYXRoIGZpbGw9IiNmZjVmMDgiIGQ9Ik0zNS42MDkgMGM3LjI5NSAwIDEzLjQyNCA1LjAyNSAxNS4xNTcgMTEuODI0LjA3My4yODUuMDUuNTg2LS4wNTQuODYybC0xLjM4OCAzLjY3N2MtMS4wNzYgMi44MzMtMy44NDIgNS4yOS02Ljk3IDUuMjlsLTI0LjEwNS4yNzhhLjUzMy41MzMgMCAwIDAtLjUyNS41MzVjMCAuMjkzLjIzNC41MzEuNTI0LjUzNWwyMy43NjUuMzE2YzIuNzI0IDAgNC4wNDYgMi41NiAzLjMxNiA1LjA4MWwtLjMwNyAxLjA2MWEuNzUuNzUgMCAwIDEtLjcxNi41NDFILjk2NGEuNzEuNzEgMCAwIDEtLjcxOC0uNjQ2IDExIDExIDAgMCAxLS4wMzQtLjg1NGMwLTUuNDc2IDQuMTY2LTkuOTczIDkuNDgyLTEwLjQ1NmE3LjIgNy4yIDAgMCAxLS4xNjctMS41NDRjMC0zLjkzNSAzLjE3LTcuMTI1IDcuMDgtNy4xMjUgMS41NTUgMCAyLjk5NC41MDYgNC4xNjIgMS4zNjJDMjIuODQ5IDQuNDk3IDI4LjcwNyAwIDM1LjYwOSAwIi8+PC9nPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDBfNzQ2XzIiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0wIDBoNjZ2MzBIMHoiLz48L2NsaXBQYXRoPjwvZGVmcz48L3N2Zz4=)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-10-22","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/"}}


description: Let a Workers AI model write JavaScript and run it in a Dynamic Worker sandbox. title: Build an AI code interpreter image: https://developers.cloudflare.com/sandbox/get-started/build-an-ai-code-interpreter/og.png?v=7b150c445b3ba3bc


Skip to 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.