Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## Unreleased

### New Features

- **Cloudflare shell sandbox.** Added `getShellSandbox({ workspace, loader })`, `getDefaultWorkspace()`, and `hydrateFromBucket()` from `@flue/runtime/cloudflare`. The new sandbox wires `@cloudflare/shell` Workspaces into Flue through a codemode `code` tool backed by a Worker Loader binding. Agents use `state.*` inside the `code` tool instead of bash/read/write/grep/glob. Use `@cloudflare/shell` directly for primitives like `Workspace`, `WorkspaceFileSystem`, and `createGit`.

### Breaking Changes

- **`getVirtualSandbox()` now throws with a migration message.** The previous API described R2 as if it were mounted directly as the harness filesystem, but `@cloudflare/shell` Workspaces are SQLite-indexed filesystems with optional R2 blob spillover; raw bucket keys uploaded outside Workspace were invisible. Migrate bucket-backed agents to `getShellSandbox({ workspace, loader })` plus `hydrateFromBucket(workspace, env.BUCKET)` before `init()`. If you used zero-arg `getVirtualSandbox()`, remove it and omit `sandbox` from `init()` to use Flue's default in-memory sandbox.

## 0.6.2 - 2026-05-14

### Fixes & Other Changes
Expand Down
31 changes: 22 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,29 +56,40 @@ export default async function ({ init, payload }: FlueContext) {

### Support Agent

A support agent can also run in a virtual sandbox, but we now add a file-system using an R2 bucket. The knowledge base is stored in R2 and mounted directly into the harness filesystem — the agent searches it with its built-in tools (grep, glob, read). Skills are also defined in the bucket that help the agent perform its task.
A support agent can also run on Cloudflare without a container by using a cf-shell Workspace. The Workspace is a durable SQLite-indexed filesystem; R2 is an optional hydration source (and large-file spillover), not a live bucket mount. Copy the R2 objects you want into the Workspace before calling `init()`, then the agent operates on that structured filesystem through the `code` tool and `state.*` API.

Because this agent is deployed to Cloudflare, message history and session state are automatically persisted for you. So you (or your customer) can revisit this support session days, weeks, or years later and pick up exactly where you left off.

```ts
// .flue/agents/support.ts
import { getVirtualSandbox } from '@flue/runtime/cloudflare';
import type { FlueContext } from '@flue/runtime';
import {
getDefaultWorkspace,
getShellSandbox,
hydrateFromBucket,
} from '@flue/runtime/cloudflare';
import * as v from 'valibot';

export const triggers = { webhook: true };

export default async function ({ init, payload, env }: FlueContext) {
// Mount the R2 knowledge base bucket as the harness filesystem.
// The agent can grep, glob, and read articles with bash, but
// without needing to spin up an entire container sandbox.
const sandbox = await getVirtualSandbox(env.KNOWLEDGE_BASE);
const harness = await init({ sandbox, model: 'openrouter/moonshotai/kimi-k2.6' });
const workspace = getDefaultWorkspace();

// Hydrate once per agent instance. R2 is a source, not a live mount.
if (!(await workspace.exists('/.hydrated'))) {
await hydrateFromBucket(workspace, env.KNOWLEDGE_BASE);
await workspace.writeFile('/.hydrated', new Date().toISOString());
}

const harness = await init({
sandbox: getShellSandbox({ workspace, loader: env.LOADER }),
model: 'openrouter/moonshotai/kimi-k2.6',
});
const session = await harness.session();

return await session.prompt(
`You are a support agent. Search the knowledge base for articles
relevant to this request, then write a helpful response.
`You are a support agent. Use the code tool to search the hydrated
workspace for articles relevant to this request, then write a helpful response.

Customer: ${payload.message}`,
{
Expand All @@ -89,6 +100,8 @@ export default async function ({ init, payload, env }: FlueContext) {
}
```

This requires a `worker_loaders` binding (`{ "worker_loaders": [{ "binding": "LOADER" }] }`) in your `wrangler.jsonc`. If you need true bucket-keys-as-filesystem-paths semantics or Linux shell commands, use `@cloudflare/sandbox` Containers with `mountBucket` instead. See [Cloudflare Shell Sandbox](https://github.com/withastro/flue/blob/main/docs/cloudflare-shell.md) for the full migration and trade-offs.

### Issue Triage (CI)

A triage agent that runs in CI whenever an issue is opened on GitHub. The `local()` sandbox gives the agent direct access to the host filesystem and shell — perfect for CI runners, where `gh`, `git`, and `npm` are already on `$PATH` and the runner itself is your isolation boundary.
Expand Down
24 changes: 16 additions & 8 deletions apps/www/src/code-snippets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,20 +28,28 @@ export const HERO = `export default async function ({ init, payload, env }) {
});
}`;

export const SUPPORT_AGENT = `import { getVirtualSandbox } from '@flue/runtime/cloudflare';
import type { FlueContext } from '@flue/runtime';
export const SUPPORT_AGENT = `import type { FlueContext } from '@flue/runtime';
import {
getDefaultWorkspace,
getShellSandbox,
hydrateFromBucket,
} from '@flue/runtime/cloudflare';

// POST /agents/support/:id
export const triggers = { webhook: true };

// Built for: Cloudflare Workers, R2
export default async function ({ init, payload, env }: FlueContext) {
// Mount your R2 bucket (declared as a binding in wrangler.jsonc) as
// the agent's filesystem at /workspace, backed by Durable Object
// SQLite + R2 under the hood. The agent searches it with bash —
// grep, glob, read — without spinning up a container.
const sandbox = await getVirtualSandbox(env.KNOWLEDGE_BASE_BUCKET);
const harness = await init({ sandbox, model: 'openrouter/moonshotai/kimi-k2.6' });
const workspace = getDefaultWorkspace();
if (!(await workspace.exists('/.hydrated'))) {
await hydrateFromBucket(workspace, env.KNOWLEDGE_BASE_BUCKET);
await workspace.writeFile('/.hydrated', new Date().toISOString());
}

const harness = await init({
sandbox: getShellSandbox({ workspace, loader: env.LOADER }),
model: 'openrouter/moonshotai/kimi-k2.6',
});
const session = await harness.session();
// Prompt! The agent harness includes your workspace AGENTS.md,
// skills, and roles (aka subagents) to complete your task as
Expand Down
12 changes: 7 additions & 5 deletions connectors/sandbox--cloudflare.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,10 +190,11 @@ That document walks through the migration end-to-end:

- Hello-world agent on Cloudflare (`flue dev --target cloudflare`).
- Adding `wrangler.jsonc`, `.env`, and `--target cloudflare` to scripts.
- Optionally adding R2-backed storage (`getVirtualSandbox(env.BUCKET)`)
if the user only needs a searchable file store and not a full Linux
container — this is often the right answer and is much cheaper than
containers.
- Optionally adding a cf-shell Workspace sandbox
(`getShellSandbox({ workspace, loader })` plus explicit R2/git
hydration) if the user only needs a searchable file store and not a
full Linux container — this is often the right answer and is much
cheaper than containers.
- Adding the Cloudflare Sandbox container at the end (which is the same
recipe as Path A above).

Expand All @@ -218,7 +219,8 @@ without first confirming the basics work on `--target cloudflare`.
version different from the `@cloudflare/sandbox` npm package version
the user actually installed. They have to match.
- The published Flue surface for Cloudflare-specific helpers is
`@flue/runtime/cloudflare` (e.g. `getVirtualSandbox`). The
`@flue/runtime/cloudflare` (e.g. `getShellSandbox`,
`getDefaultWorkspace`, `hydrateFromBucket`). The
`@cloudflare/sandbox` package is a separate Cloudflare-published
dependency the user installs themselves. Don't import from
`@flue/runtime/internal`.
172 changes: 172 additions & 0 deletions docs/cloudflare-shell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
# Cloudflare Shell Sandbox

Flue's Cloudflare shell sandbox is built on [`@cloudflare/shell`](https://www.npmjs.com/package/@cloudflare/shell): a durable, SQLite-indexed `Workspace` plus a codemode `code` tool that runs JavaScript in an isolated Worker through a `worker_loaders` binding.

The common R2 hydration flow only imports Flue helpers. Install `@cloudflare/shell` directly when you want to construct custom Workspaces or use git helpers like `WorkspaceFileSystem` and `createGit`.

This replaces the old `getVirtualSandbox(env.BUCKET)` API. That API described R2 as if it were mounted directly as the agent filesystem. That was not accurate: `Workspace` stores directory/file metadata in Durable Object SQLite and only uses R2 as blob spillover for large files written through the Workspace API. Raw R2 keys uploaded with `wrangler r2 object put` are not visible until you explicitly hydrate them into the Workspace.

## Basic Pattern

```ts
import type { FlueContext } from '@flue/runtime';
import {
getDefaultWorkspace,
getShellSandbox,
hydrateFromBucket,
} from '@flue/runtime/cloudflare';

export const triggers = { webhook: true };

export default async function ({ init, env, payload }: FlueContext) {
const workspace = getDefaultWorkspace();

if (!(await workspace.exists('/.hydrated'))) {
await hydrateFromBucket(workspace, env.KNOWLEDGE_BASE);
await workspace.writeFile('/.hydrated', new Date().toISOString());
}

const harness = await init({
sandbox: getShellSandbox({ workspace, loader: env.LOADER }),
model: 'anthropic/claude-sonnet-4-6',
});
const session = await harness.session();

return session.prompt(`Answer this using the hydrated workspace: ${payload.message}`);
}
```

Add the Worker Loader and R2 bindings to `wrangler.jsonc`:

```jsonc
{
"compatibility_flags": ["nodejs_compat"],
"worker_loaders": [{ "binding": "LOADER" }],
"r2_buckets": [{ "binding": "KNOWLEDGE_BASE", "bucket_name": "my-knowledge-base" }]
}
```

Worker Loader is currently in beta. If local `wrangler dev` does not simulate `worker_loaders`, use `wrangler dev --remote` or deploy to a preview environment.

## What The Agent Sees

The cf-shell sandbox does not expose `bash`, `grep`, `glob`, `read`, `write`, or `edit`. It exposes:

- `code` — JavaScript execution in an isolated Worker.
- `task` — Flue's framework-owned child-agent tool.

Inside the `code` tool, the model can call `state.*` methods provided by `@cloudflare/shell`, for example:

```js
async () => {
const files = await state.readdir('/');
const article = await state.readFile('/articles/reset-password.md');
return { files, excerpt: article.slice(0, 500) };
}
```

Programmatic file access still works through `session.fs` and `harness.fs`, backed by the same Workspace. Paths are Workspace paths such as `/foo.md`; there is no `/workspace` mount prefix.

`session.shell()` and `harness.shell()` throw because cf-shell has no shell. If you need Linux commands, use `@cloudflare/sandbox` Containers instead.

## Default Workspace

`getDefaultWorkspace()` constructs `new Workspace({ sql: getCloudflareContext().storage.sql })` for the current agent Durable Object.

Call it inside an agent invocation, not at module top level. Calling it twice in the same agent instance returns two handles to the same default-namespace backing store. If you need isolated workspaces inside one Durable Object, install `@cloudflare/shell` and construct them yourself with explicit namespaces:

```ts
import { Workspace } from '@cloudflare/shell';

const workspace = new Workspace({
sql: ctx.storage.sql,
namespace: 'subagent-a',
r2: env.WORKSPACE_FILES,
});
```

## R2 Hydration

`hydrateFromBucket(workspace, bucket, options?)` eagerly copies matching R2 objects into the Workspace:

```ts
await hydrateFromBucket(workspace, env.KNOWLEDGE_BASE, { prefix: 'articles/' });
```

With `prefix: 'articles/'`, a bucket key `articles/reset-password.md` becomes `/reset-password.md` in the Workspace.

Hydration is intentionally not idempotent. Use a sentinel key you own:

```ts
const sentinel = '/.hydrated-kb-v1';
if (!(await workspace.exists(sentinel))) {
await hydrateFromBucket(workspace, env.KNOWLEDGE_BASE);
await workspace.writeFile(sentinel, new Date().toISOString());
}
```

If hydration fails partway through, earlier writes remain. Re-run after fixing the error, or wipe the Durable Object storage if you need a clean rebuild.

Large files written into a Workspace may be spilled back to R2 under Workspace's own key scheme. That can duplicate large source objects once; it is correct, but not a raw bucket mount.

## Git Hydration

For git, install `@cloudflare/shell` and use its primitives directly:

```ts
import { WorkspaceFileSystem } from '@cloudflare/shell';
import { createGit } from '@cloudflare/shell/git';
import { getDefaultWorkspace } from '@flue/runtime/cloudflare';

const workspace = getDefaultWorkspace();
if (!(await workspace.exists('/.hydrated'))) {
const git = createGit(new WorkspaceFileSystem(workspace));
await git.clone({
url: 'https://github.com/FredKSchott/vinext-starter',
dir: '/repo',
depth: 1,
singleBranch: true,
});
await workspace.writeFile('/.hydrated', new Date().toISOString());
}
```

Flue does not wrap git hydration because `createGit(...).clone(...)` is already the natural API.

## Migrating From getVirtualSandbox

Old:

```ts
import { getVirtualSandbox } from '@flue/runtime/cloudflare';

const sandbox = await getVirtualSandbox(env.KNOWLEDGE_BASE);
const harness = await init({ sandbox, model: 'anthropic/claude-sonnet-4-6' });
```

New:

```ts
import {
getDefaultWorkspace,
getShellSandbox,
hydrateFromBucket,
} from '@flue/runtime/cloudflare';

const workspace = getDefaultWorkspace();
if (!(await workspace.exists('/.hydrated'))) {
await hydrateFromBucket(workspace, env.KNOWLEDGE_BASE);
await workspace.writeFile('/.hydrated', new Date().toISOString());
}

const harness = await init({
sandbox: getShellSandbox({ workspace, loader: env.LOADER }),
model: 'anthropic/claude-sonnet-4-6',
});
```

If you used `getVirtualSandbox()` with no bucket, remove the call entirely and omit `sandbox` from `init()`. Flue's default in-memory sandbox is already that behavior.

## When You Need Bucket-Keys-As-Paths

If your requirement is literally "R2 bucket keys appear as filesystem paths" or you need shell commands like `grep`, `find`, or language toolchains, use [`@cloudflare/sandbox`](https://developers.cloudflare.com/containers/) with [`mountBucket`](https://developers.cloudflare.com/sandbox/guides/mount-buckets/) instead. That gives you a real Linux container and direct bucket mount semantics. cf-shell is the lightweight Workspace + codemode path, not a Linux filesystem mount.
Loading