Use this page after the Cloudflare setup works, when you add previews, move the agent loop into the container, or plan for production.
In the default DO-drives model, the coordinator Durable Object runs chat() and the tool bridge, and the container runs only the agent CLI. The agent reaches the bridge across the container-to-Worker boundary, so the whole MCP protocol crosses that boundary.
In the co-located model, the agent loop and the tool bridge run inside the container. The in-container sandbox is localProcessSandbox(), with native stdin and a localhost node:http bridge. Only host tool execution crosses back: a chat() tool's execute() (your database, secrets, app state) lives on the coordinator. The public surface shrinks from the whole MCP protocol to one authenticated tool-exec call.
To turn it on:
| Field | What it is |
|---|---|
| mode | 'colocated' |
| harness | The CLI the runner starts: 'claude-code', 'codex', or 'opencode'. |
| model | The model id passed to that harness. |
| workspace | The workspace the runner bootstraps in the container. |
// container/runner.ts: runs inside the container, on Node.
import { claudeCodeText } from '@tanstack/ai-claude-code'
import { runInContainerHarness } from '@tanstack/ai-sandbox-cloudflare/runner'
// Listens on `port`, else the RUNNER_PORT env var, else 8080.
runInContainerHarness({
resolveAdapter: ({ model }) => claudeCodeText(model),
})The coordinator sends POST /run to the runner with the messages and the serialized tools. The runner streams StreamChunks back as NDJSON.
Four exports from @tanstack/ai-sandbox carry the tools across:
To write your own runner, this is what runInContainerHarness does inside the container:
import { chat } from '@tanstack/ai'
import { grokBuildText } from '@tanstack/ai-grok-build'
import {
defineSandbox,
defineWorkspace,
httpRemoteToolExecutor,
remoteToolStubs,
withSandbox,
} from '@tanstack/ai-sandbox'
import { localProcessSandbox } from '@tanstack/ai-sandbox-local-process'
// The body the coordinator posted: { threadId, messages, toolDescriptors,
// toolExecUrl, toolExecToken }.
import { request } from './run-request'
chat({
threadId: request.threadId,
adapter: grokBuildText('grok-build'),
messages: request.messages,
// Stubs whose execute() posts back to the coordinator. Only this one call
// leaves the container.
tools: remoteToolStubs(
request.toolDescriptors,
httpRemoteToolExecutor(request.toolExecUrl, request.toolExecToken),
),
middleware: [
withSandbox(
defineSandbox({
id: 'in-container',
provider: localProcessSandbox(),
workspace: defineWorkspace({ source: { type: 'none' } }),
}),
),
],
})Users want to see the app the agent builds. The package ships two pieces for that, both from @tanstack/ai-sandbox-cloudflare/agent:
exposePreviewTool finds the container by threadId: getSandbox(env.Sandbox, input.threadId). The stock cloudflareSandbox names its container with the sandbox key that defineSandbox computes, not with the threadId. So pin the container name with a small provider, as examples/sandbox-cloudflare does.
npm i @cloudflare/sandboxpnpm add @cloudflare/sandboxyarn add @cloudflare/sandboxbun add @cloudflare/sandbox// src/sandbox-provider.ts
import { getSandbox } from '@cloudflare/sandbox'
import {
CLOUDFLARE_CAPS,
CloudflareHandle,
} from '@tanstack/ai-sandbox-cloudflare'
import type { Sandbox } from '@cloudflare/sandbox'
import type {
SandboxCreateInput,
SandboxDestroyInput,
SandboxHandle,
SandboxProvider,
SandboxResumeInput,
} from '@tanstack/ai-sandbox'
const WORKDIR = '/workspace'
// Tunnels need the RPC transport, and every getSandbox() for one id must use
// the same transport.
const SANDBOX_OPTIONS = { transport: 'rpc' } as const
export function namedCloudflareSandbox(
binding: DurableObjectNamespace<Sandbox>,
name: string,
): SandboxProvider {
return {
name: 'cloudflare-named',
capabilities: () => CLOUDFLARE_CAPS,
async create(input: SandboxCreateInput): Promise<SandboxHandle> {
const sandbox = getSandbox(binding, name, SANDBOX_OPTIONS)
if (input.env && Object.keys(input.env).length > 0) {
await sandbox.setEnvVars(input.env)
}
await sandbox.mkdir(WORKDIR, { recursive: true })
return new CloudflareHandle(name, sandbox, WORKDIR)
},
resume: (input: SandboxResumeInput): Promise<SandboxHandle> =>
Promise.resolve(
new CloudflareHandle(
input.id,
getSandbox(binding, input.id, SANDBOX_OPTIONS),
WORKDIR,
),
),
async destroy(input: SandboxDestroyInput): Promise<void> {
await getSandbox(binding, input.id, SANDBOX_OPTIONS).destroy()
},
}
}// src/agent.ts
import { claudeCodeText } from '@tanstack/ai-claude-code'
import {
createSecrets,
defineSandbox,
defineWorkspace,
} from '@tanstack/ai-sandbox'
import {
PREVIEW_GUIDANCE,
createCloudflareSandboxAgent,
exposePreviewTool,
} from '@tanstack/ai-sandbox-cloudflare/agent'
import { namedCloudflareSandbox } from './sandbox-provider'
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'),
systemPrompts: [PREVIEW_GUIDANCE],
tools: (input, env) => [exposePreviewTool(input, env)],
sandbox: (input, env) =>
defineSandbox({
id: 'cf-edge-agent',
// The same container that exposePreview addresses.
provider: namedCloudflareSandbox(env.Sandbox, input.threadId),
workspace: defineWorkspace({
source: { type: 'none' },
secrets: createSecrets({ ANTHROPIC_API_KEY: env.ANTHROPIC_API_KEY }),
}),
lifecycle: { reuse: 'thread' },
}),
})Ask the agent to build and serve a small app. It starts the dev server, calls exposePreview, and replies with a trycloudflare.com URL.
A container named by threadId also helps reuse: 'thread': reconnects and previews reach the same container, even after the coordinator Durable Object is evicted.
The dev server must accept the tunnel hostname, because servers reject unknown hosts. PREVIEW_GUIDANCE tells the agent this:
For a complete app that runs Claude Code, Codex, or Grok Build (picked in the UI or with the HARNESS var), see examples/sandbox-cloudflare.
exposePort plus proxyToSandbox routes the preview through the Worker's own origin. In local dev, that origin is your Vite dev server. Vite's middleware then serves the preview's module and asset requests (/@vite/client, /src/*, /@fs/*) from your host, not the container. The page loads the wrong code and breaks.
A quick tunnel runs cloudflared inside the sandbox (the cloudflare/sandbox base image ships it). So:
exposePort with resolvePreviewHost stays available for apps that want the Worker in front of the preview, on a custom domain.
sandbox.tunnels exists only on the SDK's RPC transport. On the default http transport it throws "requires the RPC transport". So:
Set transport: 'http' only if you use no tunnel previews.
The container is off-isolate compute. It cannot use a service binding or an in-process call to reach the Worker, only the network. So its callback URLs need real hosts. There are two surfaces, each with its own resolver in @tanstack/ai-sandbox-cloudflare/agent:
| Surface | Who calls it | Resolver | Config |
|---|---|---|---|
| Bridge and tool-exec (/_bridge, /tool-exec) | The container calls the Worker. | resolveBridgeOrigin | PUBLIC_HOSTNAME, optional. |
| Preview (exposePort) | The browser calls the Worker, which calls the container. | resolvePreviewHost | PREVIEW_HOSTNAME, needs wildcard DNS. |
Bridge. It only has to reach the Worker. When PUBLIC_HOSTNAME is unset, the host comes from the POST /runs request:
Taking the host from the request is safe on Cloudflare. The edge sends a request to your Worker only when its hostname matches a route you own, so the Host is always one of yours. The per-run bearer token in the URL cannot be sent to another domain. On a plain Node server, the Host header is attacker-controlled, so the same trick leaks the token (an SSRF risk). See Tools for the non-edge bridge.
Preview (the exposePort path only). It needs wildcard DNS, so PREVIEW_HOSTNAME is a separate setting:
{
"routes": [{ "pattern": "*.preview.example.com/*", "zone_name": "example.com" }],
"vars": { "PREVIEW_HOSTNAME": "preview.example.com" }
}The coordinator drives each run with core's run driver, RunController from @tanstack/ai-sandbox. Two adapters bind it to the Durable Object run log (DurableObjectRunEventLog):
Both come from @tanstack/ai-sandbox-cloudflare/agent, for apps that compose the pieces themselves. They are why the portable tools (alignToStoredLog, replayRunStream) work on the Durable Object log the same way as on memoryStream or durableStream.
The words are core's too:
1.0 does not read 0.x run records (done or error statuses, createdAt / updatedAt fields). Reading one throws an error. Before you upgrade, let in-flight runs finish or delete them, then clear the old rec: keys from the Durable Object storage.
The journal keeps its job on the driver side: warm reattach, and the driver's resume position. The rule is the portable one: the log wins for what clients see, and the journal wins for where the driver resumes.
What is specific to Cloudflare is the Durable Object's supervised lifetime. The driver outlives any request, and alarm() gives it a scheduler. Off Cloudflare, takeover does that job, which is why the journal is required on every tier.
Keep these apart. Each one lives somewhere different on Cloudflare. Mixing them up is how workspace files end up "durably" in /tmp.
| Layer | Where it lives on Cloudflare | See |
|---|---|---|
| Run and events | The coordinator's run log, in Durable Object storage | The Run Journal, Takeover |
| Workspace (files the agent edits) | The container filesystem. Not durable. Provision it again on create. | Workspace, Provisioning |
| Artifacts (outputs worth keeping) | R2, through a persistence BlobStore or ArtifactStore | Keep generated files |