Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions examples/sandbox-chat/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
R2_ACCESS_KEY_ID=your-r2-access-key-id
R2_SECRET_ACCESS_KEY=your-r2-secret-access-key
CLOUDFLARE_ACCOUNT_ID=your-account-id
CLOUDFLARE_API_KEY=your-cloudflare-api-key
9 changes: 9 additions & 0 deletions examples/sandbox-chat/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
node_modules/
dist/
.wrangler
.DS_Store
.env
.env.*
!.env.example
*.tsbuildinfo
.vite/
98 changes: 98 additions & 0 deletions examples/sandbox-chat/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# sandbox-chat

AI chat assistant backed by an isolated Linux container via the Cloudflare Sandbox SDK. Users interact through a three-panel UI: file browser, chat, and shared terminal.

## Architecture

```
Browser (React)
├── File browser sidebar ← @callable RPCs (listFiles, readFileContent, …)
├── Chat panel ← WebSocket via useAgent / useAgentChat
└── Terminal + Preview panel ← SandboxAddon WebSocket (?mode=terminal)
SandboxChatAgent (Durable Object, AIChatAgent)
├── AI tools (readFile, writeFile, exec, git*, coder, exposePort, …)
├── Shared PTY fan-out (1 upstream → N browser clients)
├── OpenCode delegation (coder tool)
├── File watcher (inotify → broadcast)
└── R2 backup / restore
Sandbox Container (Durable Object + Container)
├── docker.io/cloudflare/sandbox:0.8.0-opencode
├── Node.js, Bun, Python, git, standard Unix tools
├── OpenCode server on port 4096
└── Web service ports 8000-8005
```

## Files

| File | Purpose |
|------|---------|
| `src/server.ts` | `SandboxChatAgent` DO — all AI tools, PTY multiplexing, OpenCode integration, file watcher, backup/restore, preview proxy |
| `src/client.tsx` | React app — chat UI, file browser sidebar, xterm.js terminal, web preview iframe, resize handles |
| `src/sandbox-workspace.ts` | `SandboxWorkspace` adapter — wraps `@cloudflare/sandbox` with an interface matching `@cloudflare/shell`'s Workspace |
| `src/opencode-stream.ts` | `OpenCodeStreamAccumulator` — translates OpenCode SSE events into AI SDK `UIMessage[]` for the coder subagent |
| `src/index.tsx` | React entry point |
| `src/styles.css` | Tailwind v4 + Kumo design system imports |
| `wrangler.jsonc` | Worker config — containers, DOs, R2, AI binding, assets |
| `Dockerfile` | Extends `sandbox:0.8.0-opencode`, exposes ports 4096, 8000-8005 |

## Key patterns

### Shared PTY terminal

A single upstream PTY WebSocket connects to the sandbox container via `sandbox.terminal()`. All browser terminal clients (`?mode=terminal` connections) are identified by the `"terminal"` connection tag (hibernation-safe via `getConnectionTags`). Output fans out from the upstream PTY to all tagged clients; keystrokes are forwarded back.

The `exec` tool sends commands through this same PTY via `ptyExec()`, so the user sees agent commands execute in real-time. A deterministic PS1 prompt marker (derived from `this.name`) detects command completion.

**Important**: The PTY is never torn down on client disconnect — the container's bash session persists and reconnecting clients reattach. The prompt marker must be deterministic to survive DO hibernation.

### Coder tool (OpenCode subagent)

The `coder` tool delegates complex coding tasks to an autonomous OpenCode agent inside the container. It uses an **async generator** that yields `CoderToolOutput` objects as preliminary tool results, each containing a growing `UIMessage[]` sub-conversation.

The `OpenCodeStreamAccumulator` (in `src/opencode-stream.ts`) translates OpenCode SSE events into AI SDK-native `UIMessage` parts (`TextUIPart`, `DynamicToolUIPart`). Each SSE event updates the accumulator's state, and the generator yields throttled snapshots (~200ms) as preliminary tool results. The client renders these as a full nested conversation using the `CoderSubConversation` component — showing the agent's text responses and tool calls in real-time with the same rendering components as the main chat.

The SSE event stream is consumed via direct `containerFetch` + `parseSSEStream` (not the SDK's `client.event.subscribe()` which buffers through the container fetch adapter). An abort signal and 2-minute inactivity timeout prevent indefinite hangs.

### Web preview

The `exposePort` tool exposes container ports (8000-8005) and broadcasts preview URLs to clients via a `preview-url` ServerMessage. The client renders an iframe with the preview URL. Port 3000 is reserved (sandbox control plane) and must never be used.

The hostname for preview URLs is captured from the first incoming request's `url.host` (includes port for local dev, e.g. `localhost:5173`). Preview subdomain requests are handled by `proxyToSandbox` in the fetch handler.

### File watcher

Uses `sandbox.watch()` with inotify to stream filesystem changes, broadcast as `file-change` ServerMessages. The file browser debounces these into refreshes. The watcher starts on first non-terminal client connect and stops when all disconnect.

### Backup / restore

Workspace persistence across container eviction uses `sandbox.createBackup()` / `sandbox.restoreBackup()` with handles stored in DO SQLite storage. Runs in both local dev (miniflare-simulated R2) and production.

## Ports

| Port | Use |
|------|-----|
| 3000 | **Reserved** — sandbox control plane, never use |
| 4096 | OpenCode server (internal) |
| 8000-8005 | Available for web services started by the coder/user |

## Environment variables

Set in `.env` for local development (see `.env.example`):

| Variable | Purpose |
|----------|---------|
| `CLOUDFLARE_ACCOUNT_ID` | Required for OpenCode's Workers AI provider |
| `CLOUDFLARE_API_KEY` | Required for OpenCode's Workers AI provider |
| `R2_ACCESS_KEY_ID` | Optional — R2 backup persistence |
| `R2_SECRET_ACCESS_KEY` | Optional — R2 backup persistence |

## Run locally

```bash
npm install
npm start # requires Docker running
```

First run builds the container image (2-3 minutes).
9 changes: 9 additions & 0 deletions examples/sandbox-chat/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
FROM docker.io/cloudflare/sandbox:0.8.0-opencode

WORKDIR /workspace

# Expose OpenCode server port
EXPOSE 4096

# Expose ports for web services (8000-8005)
EXPOSE 8000-8005
136 changes: 136 additions & 0 deletions examples/sandbox-chat/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# Sandbox Chat

An AI chat assistant backed by an isolated Linux container via the [Sandbox SDK](https://developers.cloudflare.com/sandbox/). The agent can read, write, and manage files, run shell commands, use git, delegate to a coding agent, and expose web server previews — all in a persistent, sandboxed environment.

## What this demonstrates

- **Container-backed file operations** — read, write, list, delete, mkdir, glob via `@cloudflare/sandbox`
- **Shell command execution** — run any command (bash, node, python, git) via a private agent PTY
- **Git integration** — init, status, add, commit, log, diff using real git CLI with porcelain output
- **Coding agent delegation** — hand off complex tasks to OpenCode running inside the container
- **Web preview** — expose container ports (8000–8005) and display them in an iframe
- **File watching** — inotify-based filesystem watcher broadcasts changes to the UI in real-time
- **Persistent workspace** — files survive container eviction via R2 backup/restore
- **Dual terminals** — agent has its own private PTY; the user gets an independent terminal via SandboxAddon

## File layout

```
src/
server.ts # SandboxChatAgent — orchestrator, tool definitions, chat handler
server/
sandbox-workspace.ts # SandboxWorkspace adapter (wraps ISandbox)
pty.ts # AgentPty — private PTY for agent exec
coder.ts # CoderManager — OpenCode delegation + progress summarization
file-watcher.ts # FileWatcher — inotify → broadcast
git-parsers.ts # Git output parsers (status, log, diff)
preview.ts # PreviewManager — port exposure + URL state
backup.ts # Backup/restore helpers
types.ts # ServerMessage, CoderOutput
multi-pty.ts # Archived: shared-PTY fan-out approach (reference only)
client.tsx # App shell — layout, session, wiring
client/
file-browser.tsx # File browser sidebar
chat-messages.tsx # Message list + tool card renderers
terminal-panel.tsx # User-interactive terminal (SandboxAddon)
preview-panel.tsx # Web preview iframe
connection-indicator.tsx # Connection status dot
mode-toggle.tsx # Dark/light theme toggle
resize-handle.tsx # Draggable panel divider
index.tsx # React entry point
styles.css # Tailwind v4 + Kumo imports
```

## Prerequisites

- [Docker](https://docs.docker.com/desktop/) running locally (required for the sandbox container)
- [Node.js](https://nodejs.org/) 24+
- A Cloudflare account (Workers Paid plan for Containers)

## Run locally

```bash
npm install
npm start
```

> First run builds the Docker container image (2–3 minutes). Subsequent runs are faster.

## Environment variables

For the coding agent (OpenCode) and workspace persistence, set credentials in `.env` (see `.env.example`):

```
CLOUDFLARE_ACCOUNT_ID=your-account-id
CLOUDFLARE_API_KEY=your-api-key
R2_ACCESS_KEY_ID=your-r2-access-key-id
R2_SECRET_ACCESS_KEY=your-r2-secret-access-key
```

Without R2 credentials, the chat still works — files just won't survive container eviction.
Without `CLOUDFLARE_API_KEY`, the `coder` tool will report an error but all other tools work.

## Deploy

```bash
npm run deploy
```

Then set secrets for production:

```bash
npx wrangler secret put CLOUDFLARE_ACCOUNT_ID
npx wrangler secret put CLOUDFLARE_API_KEY
npx wrangler secret put R2_ACCESS_KEY_ID
npx wrangler secret put R2_SECRET_ACCESS_KEY
```

## Key patterns

### SandboxWorkspace adapter

The `SandboxWorkspace` class wraps `@cloudflare/sandbox` with the same method signatures as `@cloudflare/shell`'s `Workspace`:

```typescript
const sandbox = getSandbox(env.Sandbox, agentName);
const sw = new SandboxWorkspace(sandbox);

// Same API as Workspace
const content = await sw.readFile("/workspace/index.ts"); // string | null
const entries = await sw.readDir("/workspace/src"); // FileInfo[]
await sw.writeFile("/workspace/hello.txt", "Hello!");
```

### Agent PTY

The agent has a private terminal session for running commands. No fan-out to browser clients — the user gets their own independent terminal via `SandboxAddon`:

```typescript
// Server: agent uses AgentPty for exec
const pty = new AgentPty(env.Sandbox, agentName);
await pty.ensureReady();
const { output, timedOut } = await pty.exec("npm install");

// Client: user connects directly to sandbox
const sandbox = new SandboxAddon({
getWebSocketUrl: ({ origin }) =>
`${origin}/agents/sandbox-chat-agent/${name}?mode=terminal`
});
```

### Backup/restore persistence

```typescript
// After mutations
const backup = await sw.createBackup();
await this.ctx.storage.put("backup", JSON.stringify(backup));

// On container restart
const raw = await this.ctx.storage.get("backup");
if (raw) await sw.restoreBackup(JSON.parse(raw));
```

## Related examples

- [`workspace-chat`](../workspace-chat/) — same concept using `@cloudflare/shell` (virtual filesystem, no container)
- [`playground`](../playground/) — kitchen-sink showcase of all SDK features
23 changes: 23 additions & 0 deletions examples/sandbox-chat/env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/* eslint-disable */
// Hand-written env.d.ts — regenerate with `wrangler types env.d.ts --include-runtime false`
declare namespace Cloudflare {
interface GlobalProps {
mainModule: typeof import("./src/server");
durableNamespaces: "SandboxChatAgent" | "Sandbox";
}
interface Env {
AI: Ai;
Sandbox: DurableObjectNamespace<import("@cloudflare/sandbox").Sandbox>;
SandboxChatAgent: DurableObjectNamespace<
import("./src/server").SandboxChatAgent
>;
BACKUP_BUCKET: R2Bucket;
BACKUP_BUCKET_NAME: string;
CLOUDFLARE_ACCOUNT_ID: string;
CLOUDFLARE_API_KEY: string;
R2_ACCESS_KEY_ID: string;
R2_SECRET_ACCESS_KEY: string;
}
}

interface Env extends Cloudflare.Env {}
21 changes: 21 additions & 0 deletions examples/sandbox-chat/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.ico" />
<title>Sandbox Chat</title>
<script>
(() => {
const stored = localStorage.getItem("theme");
const mode = stored || "light";
document.documentElement.setAttribute("data-mode", mode);
document.documentElement.style.colorScheme = mode;
})();
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
41 changes: 41 additions & 0 deletions examples/sandbox-chat/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"name": "@cloudflare/agents-sandbox-chat-example",
"description": "AIChatAgent with Sandbox — AI assistant that can read, write, and manage files in a container-backed sandbox",
"private": true,
"type": "module",
"version": "0.0.0",
"scripts": {
"start": "vite dev",
"deploy": "vite build && wrangler deploy",
"types": "wrangler types env.d.ts --include-runtime false"
},
"dependencies": {
"@cloudflare/ai-chat": "*",
"@cloudflare/kumo": "^1.16.0",
"@cloudflare/sandbox": "^0.7.20",
"@opencode-ai/sdk": "^1.1.40",
"@phosphor-icons/react": "^2.1.10",
"@streamdown/code": "^1.1.1",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"agents": "*",
"ai": "^6.0.141",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"streamdown": "^2.5.0",
"workers-ai-provider": "^3.1.8",
"zod": "^4.3.6"
},
"devDependencies": {
"@cloudflare/vite-plugin": "^1.30.2",
"@cloudflare/workers-types": "^4.20260317.1",
"@tailwindcss/vite": "^4",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.2",
"vite": "^8.0.3",
"wrangler": "^4.78.0"
}
}
Binary file added examples/sandbox-chat/public/favicon.ico
Binary file not shown.
Loading