Run the Pi ↗︎ coding agent in a Linux sandbox to edit a GitHub repository from a prompt, then read its changes as a diff. Pi calls models through AI Gateway with its built-in provider. Your Worker adds the gateway token to those requests, and the sandbox can reach only your gateway and github.com.
- The
sandbox-coding-agentproject from Build a coding agent runner. This page replaces the image, the model, and the parts of the Durable Object that are specific to the agent. - Anthropic credentials in AI Gateway, either Unified Billing credits or an Anthropic API key stored as a provider key.
-
Replace the
Dockerfile. Only the highlighted line differs from theDockerfilein the tutorial: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/* RUN npm install --global --ignore-scripts @earendil-works/pi-coding-agent@0.87.1 COPY --from=docker.io/cloudflare/sandbox:1.0.0 /usr/local/bin/sandbox-shim /usr/local/bin/sandbox-shim WORKDIR /workspace CMD ["sleep", "infinity"]The image pins the version, because the flags and event format of Pi can change between releases.
-
In
wrangler.jsonc, setMODELto a model ID from thecloudflare-ai-gatewayprovider in Pi:{ "vars": { "AI_GATEWAY_ACCOUNT_ID": "<ACCOUNT_ID>", "AI_GATEWAY_ID": "default", "MODEL": "claude-sonnet-5", }, }[vars] AI_GATEWAY_ACCOUNT_ID = "<ACCOUNT_ID>" AI_GATEWAY_ID = "default" MODEL = "claude-sonnet-5"Use a model from a provider such as Anthropic. For most Workers AI models in its catalog, Pi 0.87.1 requests as many output tokens as the context window. Workers AI rejects those requests.
-
In
src/sandbox.ts, replace theResultEventconstant with a schema for the Pi events that report the outcome:src/sandbox.tsts const PiEvent = z.union([ z.object({ type: z.literal("agent_settled") }), z.object({ type: z.literal("message_end"), message: z.object({ role: z.literal("assistant"), content: z.array( z.object({ type: z.string(), text: z.string().optional() }), ), stopReason: z.string(), errorMessage: z.string().optional(), }), }), ]); type PiMessage = Extract< z.infer<typeof PiEvent>, { type: "message_end" } >["message"]; -
Replace the
agentCommand()andagentEnv()methods:src/sandbox.tsts export class AgentSandbox extends DurableObject<Env> { // ... private agentCommand(prompt: string): string[] { return [ "pi", // Run one task, write one JSON event per line to the events file, and exit "--mode", "json", // Keep the session in memory instead of saving it to disk "--no-session", // Select the built-in AI Gateway provider in Pi, which reads the account // and gateway IDs from the environment "--provider", "cloudflare-ai-gateway", // Select the model from `MODEL` "--model", this.env.MODEL, "--", prompt, ]; } private agentEnv(): Record<string, string> { return { CLOUDFLARE_API_KEY: "provided-by-worker", CLOUDFLARE_ACCOUNT_ID: this.env.AI_GATEWAY_ACCOUNT_ID, CLOUDFLARE_GATEWAY_ID: this.env.AI_GATEWAY_ID, PI_OFFLINE: "1", PI_SKIP_VERSION_CHECK: "1", PI_TELEMETRY: "0", }; } }Pi runs its tools without approval prompts. The container limits what commands from Pi can reach.
Pi does not start without an API key, so
CLOUDFLARE_API_KEYis a placeholder. Pi sends the key in thecf-aig-authorizationheader, and theOutboundentrypoint replaces it with the gateway token.PI_OFFLINE,PI_SKIP_VERSION_CHECK, andPI_TELEMETRYturn off the model catalog refresh, version check, and telemetry in Pi.Pi still loads
AGENTS.mdandCLAUDE.mdfiles from the repository. Without the--approveflag, it skips the protected project resources in the.pidirectory of the repository. Leave--approveoff for repositories you do not trust. -
Replace the
outcome()method:src/sandbox.tsts export class AgentSandbox extends DurableObject<Env> { // ... private async outcome(exitCode: number): Promise<TaskStatus> { if (exitCode !== 0) { const stderr = (await this.readOptionalText(stderrPath)) ?? ""; return { state: "failed", error: `pi exited with ${exitCode}: ${stderr.slice(-2000)}`, }; } let settled = false; let final: PiMessage | undefined; const events = await this.files.readFile(eventsPath); for await (const line of readLines(events.body)) { const parsed = PiEvent.safeParse(parseJson(line)); if (!parsed.success) { continue; } if (parsed.data.type === "agent_settled") { settled = true; } else { final = parsed.data.message; } } if (!settled || final === undefined) { return { state: "failed", error: "pi exited before the agent settled" }; } if (final.stopReason === "error" || final.stopReason === "aborted") { return { state: "failed", error: final.errorMessage ?? `pi stopped: ${final.stopReason}`, }; } const text = final.content.flatMap((block) => block.type === "text" ? [block.text ?? ""] : [], ); return { state: "succeeded", result: text.join("") }; } }Pi exits successfully even when a model request fails, so the outcome comes from its events. The task succeeds when Pi sends an
agent_settledevent and its last assistant message has astopReasonother thanerrororaborted. Otherwise, the task fails with the error message from Pi. -
Deploy your Worker:
npx wrangler deployyarn wrangler deploypnpm wrangler deployThe
AI_GATEWAY_TOKENsecret from the tutorial stays in place. A running container keeps the image it started with, so use a new sandbox name for Pi.
-
Clone a repository into a sandbox named
pi-1:curl "$WORKER_URL/sandboxes/pi-1/repository" \ --json '{"url": "https://github.com/octocat/Hello-World"}' -
Start a task:
curl "$WORKER_URL/sandboxes/pi-1/task" \ --data "Add a file NOTES.md with a one-sentence summary of this repository. Do not commit." -
Check the task until its state is
succeededorfailed:curl "$WORKER_URL/sandboxes/pi-1/task"A
succeededtask includes the text of the last reply from Pi. -
Read the changes:
curl "$WORKER_URL/sandboxes/pi-1/diff"The diff shows the new
NOTES.mdfile.
- Build a coding agent runner: how the runner starts the sandbox, runs the task, and reads the diff.
- Pi example ↗︎: a separate deployable Worker that runs Pi.
- Pi in AI Gateway: route Pi through AI Gateway from your own terminal.