The Cloudflare sandbox runs the agent loop, the run history, and the container in one deploy. A run continues after the user closes the tab.
@tanstack/ai-sandbox-cloudflare gives you both. createCloudflareSandboxAgent builds the Worker routes, a coordinator Durable Object that drives each run and keeps its log, and the container plumbing.
For the provider-neutral basics (workspace, tools, policy, lifecycle), start at the Overview.
npm i @tanstack/ai @tanstack/ai-sandbox @tanstack/ai-sandbox-cloudflare @tanstack/ai-sandbox-local-process @tanstack/ai-claude-code zodpnpm add @tanstack/ai @tanstack/ai-sandbox @tanstack/ai-sandbox-cloudflare @tanstack/ai-sandbox-local-process @tanstack/ai-claude-code zodyarn add @tanstack/ai @tanstack/ai-sandbox @tanstack/ai-sandbox-cloudflare @tanstack/ai-sandbox-local-process @tanstack/ai-claude-code zodbun add @tanstack/ai @tanstack/ai-sandbox @tanstack/ai-sandbox-cloudflare @tanstack/ai-sandbox-local-process @tanstack/ai-claude-code zodnpm i -D wranglerpnpm add -D wrangleryarn add -D wranglerbun add -d wranglerFROM docker.io/cloudflare/sandbox:0.12.1
RUN npm install -g @anthropic-ai/claude-code --include=optional && claude --version{
"name": "edge-agent",
"main": "src/worker.ts",
"compatibility_date": "2026-05-01",
"compatibility_flags": ["nodejs_compat"],
"containers": [{ "class_name": "Sandbox", "image": "./Dockerfile", "max_instances": 5 }],
"durable_objects": {
"bindings": [
// The coordinator that owns each run and its log.
{ "name": "RUN_COORDINATOR", "class_name": "RunCoordinator" },
// The container host.
{ "name": "Sandbox", "class_name": "Sandbox" }
]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["RunCoordinator", "Sandbox"] }
],
"vars": { "SANDBOX_TRANSPORT": "rpc" }
}// src/agent.ts
import { claudeCodeText } from '@tanstack/ai-claude-code'
import {
createSecrets,
defineSandbox,
defineWorkspace,
} from '@tanstack/ai-sandbox'
import { cloudflareSandbox } from '@tanstack/ai-sandbox-cloudflare'
import { createCloudflareSandboxAgent } from '@tanstack/ai-sandbox-cloudflare/agent'
import type { SandboxAgentEnv } from '@tanstack/ai-sandbox-cloudflare/agent'
export interface AppEnv extends SandboxAgentEnv {
ANTHROPIC_API_KEY: string
}
export const agent = createCloudflareSandboxAgent<AppEnv>({
adapter: () => claudeCodeText('claude-opus-4-8'),
sandbox: (_input, env) =>
defineSandbox({
id: 'cf-edge-agent',
provider: cloudflareSandbox({ binding: env.Sandbox }),
workspace: defineWorkspace({
// The image already has the CLI. Nothing to clone.
source: { type: 'none' },
secrets: createSecrets({ ANTHROPIC_API_KEY: env.ANTHROPIC_API_KEY }),
}),
// One sandbox per thread, so a follow-up run reuses the workspace.
lifecycle: { reuse: 'thread' },
}),
})// src/worker.ts
import { agent } from './agent'
// The class names in wrangler.jsonc.
export const RunCoordinator = agent.Coordinator
export const Sandbox = agent.Sandbox
export default agent.workernpx wrangler secret put ANTHROPIC_API_KEY
npx wrangler deployPOST /runs starts a run and returns 202 { runId } at once. Then open a WebSocket on /runs/<runId>/stream to tail it.
export async function startRun(threadId: string, prompt: string) {
const response = await fetch('/runs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
threadId,
messages: [{ role: 'user', content: prompt }],
}),
})
const body: unknown = await response.json()
if (
typeof body !== 'object' ||
body === null ||
!('runId' in body) ||
typeof body.runId !== 'string'
) {
throw new Error('POST /runs returned no runId')
}
return body.runId
}
// Pass the last `seq` you saw to resume after a drop. -1 replays from the start.
export function tailRun(threadId: string, runId: string, lastSeq = -1) {
const url = new URL(`/runs/${runId}/stream`, window.location.href)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
url.searchParams.set('threadId', threadId)
url.searchParams.set('lastSeq', String(lastSeq))
const socket = new WebSocket(url)
socket.addEventListener('message', (message) => {
// `{ seq, chunk }` per event, then one `{ type: 'status', record }`.
console.log(JSON.parse(String(message.data)))
})
return socket
}Call startRun, then tailRun. Close the tab in the middle, open it again, and tail with your last seq: the run kept going.
This is the default DO-drives model. In the co-located model, the agent loop runs inside the container instead. See Cloudflare Edge Details.
Cloudflare is the log-first tier of the durable runs protocol. The coordinator's run log is the durable copy. A closed tab or dropped WebSocket only detaches the tail: the coordinator keeps driving, and the log keeps filling.
Two rules matter in production:
Warning
The Sandbox container class has a sleepAfter idle timeout. A detached run that is quiet (nobody connected, the agent thinking) can look idle, and an idle container sleeps. The run then "dies for no reason", while the log keeps its prefix. Set sleepAfter well above your longest quiet stretch before you rely on detached runs.
createCloudflareSandboxAgent sets one stall policy for the whole app. stallTimeoutMs applies to every run, in both do-drives (the default) and colocated mode:
| Value | Behavior |
|---|---|
| Omitted | A run with no persisted activity for 300000 ms (five minutes) is stalled. |
| Positive safe integer | Use that many milliseconds as the limit. |
| false | No stall detection. |
To allow ten quiet minutes, add one line next to adapter in src/agent.ts:
export const agent = createCloudflareSandboxAgent<AppEnv>({
adapter: () => claudeCodeText('claude-opus-4-8'),
+ stallTimeoutMs: 10 * 60_000,
sandbox: (_input, env) =>How it decides:
On a stall, the watchdog marks the run log failed. It does not promise to kill the agent process or the container. Use provider lifecycle controls and the reaper for cleanup.
For the co-located model, live previews, callback hosts, the run log internals, and what edge durability does not cover, see Cloudflare Edge Details.