Skip to content

Cloudflare Filestore Workspace

The CloudflareFileStoreWorkspace is the lightest Cloudflare option for durable file storage. Files live in the agent's own Durable Object SQLite via @cloudflare/shell's Workspace. No container, no cold start, optional R2 binding for large-file spill.

When to use

  • Cloudflare DO–hosted agents that need durable file storage.
  • You want files to survive DO hibernation cleanly with no recovery dance.
  • You don't need a shell or code interpreter — just files.

If you need shell or code execution on Cloudflare, use Cloudflare Sandbox.

Capabilities supported

CapabilitySupported
fs
shell
code
snapshot

Declaring a capability marked ❌ above causes WorkspaceFailedError at session start (the framework asserts that config.capabilities ⊆ ref.capabilities and that each declared module is present on the returned workspace). See the error-model table on the workspaces overview.

Provider config

typescript
interface CloudflareFileStoreWorkspaceConfig {
  kind: 'cloudflare-filestore';
  /** Scope for table names within the DO's SQLite. Defaults to a sanitized form of the session ID. */
  namespace?: string;
  /** Name of the R2 bucket binding on `env`, used for large-file spill. */
  r2Binding?: string;
  /** Byte threshold above which files spill to R2. Defaults to @cloudflare/shell's 1.5MB. */
  inlineThreshold?: number;
}

Wrangler setup

The agent DO and the filestore share the SAME DO. No separate sandbox container required.

toml
[[durable_objects.bindings]]
name = "AGENTS"
class_name = "MyAgentServer"

[[migrations]]
tag = "v1"
new_sqlite_classes = ["MyAgentServer"]

# Optional: R2 bucket for large-file spill
[[r2_buckets]]
binding = "FILES_R2"
bucket_name = "my-files"

new_sqlite_classes is required — @cloudflare/shell uses SQLite-backed DO storage.

Migrating from JS to Cloudflare DO (round-5 D9)

The workspaceProviders shape differs between the JS executor and the Cloudflare DO runtime. This catches first-time migrators with a baffling type error.

diff
// JS executor — eagerly-constructed Map
- const executor = new JSAgentExecutor(stateStore, streamManager, llm, {
-   workspaceProviders: new Map([['in-memory', new InMemoryWorkspaceProvider()]]),
- });

// Cloudflare DO — factory function (env, ctx) => Map
+ export const MyAgentServer = createAgentServer<Env>({
+   workspaceProviders: (env, ctx) =>
+     new Map([['cloudflare-filestore', new CloudflareFileStoreWorkspaceProvider(ctx, env)]]),
+ });

Why the difference. In a JS process, you have access to all your dependencies at module load time — you can construct provider instances eagerly. In a Cloudflare Worker, env (wrangler bindings) and ctx (DO storage handle) are not available at module load — they only flow in per-DO when the runtime instantiates your DO. The factory defers provider construction until that per-DO context is established.

The same factory pattern applies to workspaceMetrics, llmAdapter, and any other per-DO–scoped construction in createAgentServer.

Provider wiring

typescript
import { createAgentServer, AgentRegistry, CloudflareFileStoreWorkspaceProvider } from '@helix-agents/runtime-cloudflare';
import type { WorkspaceProvider } from '@helix-agents/core';

interface Env {
  AGENTS: DurableObjectNamespace;
  FILES_R2?: R2Bucket;  // optional
  OPENAI_API_KEY: string;
}

export const MyAgentServer = createAgentServer<Env>({
  llmAdapter: (env) => /* ... */,
  agents: registry,
  workspaceProviders: (env, ctx) =>
    new Map<string, WorkspaceProvider>([
      ['cloudflare-filestore', new CloudflareFileStoreWorkspaceProvider(ctx, env)],
    ]),
});

The provider takes (ctx, env, options?) because it needs ctx.storage.sql for the SQLite-backed file store. The env parameter is typed as object, so a typed wrangler-shape Env interface flows through directly — no as unknown as Record<string, unknown> cast required at the boundary. The provider only narrows to a record-shaped view internally when an r2Binding is configured and the named binding actually has to be looked up.

The options bag accepts:

  • logger?: Logger — see Observability below.
  • maxGlobalConcurrentOpens?: number (round-5 B2) — process-wide bound on concurrent open() and resolve() calls into this provider, ACROSS all sessions sharing the provider instance. Defaults to Infinity (unbounded). Filestore opens are very cheap (no container, no R2 round-trip; just a SQLite namespace reattach) so the practical motivator is preventing R2-spill saturation on burst restore-from-snapshot ops rather than per-binding max_instances. Layered with WorkspaceRegistryDeps.maxConcurrentOpens (per-session). See Cloudflare Sandbox tuning for the layered-semaphore rationale.

Observability

The provider accepts an optional Logger from @helix-agents/core so workspace-side events (today: forwarded into the filesystem module for future hardening; tomorrow: any policy or eviction event the provider grows) surface in your logging pipeline:

typescript
import { consoleLogger } from '@helix-agents/core';

new CloudflareFileStoreWorkspaceProvider(ctx, env, { logger: consoleLogger });

Defaults to silent (noopLogger). The constructor accepts a third options arg so the public surface stays uniform across providers.

Auto-injected tools

All fs tools (see FileSystem module for schemas):

  • workspace__<name>__read_file, write_file, edit_file, ls, glob, grep, stat, mkdir, rm

Using the workspace from a custom tool

typescript
import { defineTool } from '@helix-agents/core';
import { z } from 'zod';

const summarizeNotes = defineTool({
  name: 'summarize_notes',
  parameters: z.object({}),
  execute: async (_input, ctx) => {
    const ws = await ctx.workspaces!.get('notes');
    if (!ws.fs) throw new Error('notes workspace requires fs capability');
    const entries = await ws.fs.ls('/notes');
    return { count: entries.length };
  },
});

See the shared pattern on the overview pageawait on get() is required, and the ! non-null assertion on ctx.workspaces is appropriate when the agent declares a workspace.

Inspecting a workspace

Files live in the agent DO's SQLite under namespaced tables managed by @cloudflare/shell. Two paths to peek at contents:

  • From inside the agent (recommended). Add a custom debug tool calling ws.fs!.ls('/') or ws.fs!.glob('**/*'). Works in production without any out-of-band tooling.
  • From the host. Direct SQL against the DO's SQLite (e.g. via wrangler against the local development storage) is possible, but the table-naming scheme is internal to @cloudflare/shell and may change. Treat it as a debug-only convenience; the custom-tool path is the supported integration surface.

Mid-run inspection (active sessions)

Inspecting an ACTIVE session — one with the agent currently executing — needs care: the agent may write while you read.

  • Recommended for active sessions. The custom debug-tool path (in-agent) — the read happens on the same I/O thread as the agent's writes, so no race.
  • From wrangler (read-only safe). wrangler d1 execute with a SELECT is read-only against SQLite — safe to run mid-session for human inspection. Example shape (verify the actual table name from the @cloudflare/shell version you have installed):
    bash
    wrangler d1 execute MY_DB --command "SELECT path, length(content) FROM workspace_files WHERE namespace = 's_yourSession'"
    Writes from wrangler would race the agent — never use INSERT/UPDATE/DELETE against a live session's storage.
  • For after-completion inspection. Either approach is safe; the agent has stopped writing.

The table name and schema are internal to @cloudflare/shell; check the version's source for the current shape before relying on this.

Code sample (full integration)

For a complete runnable repo, see examples/research-assistant-cloudflare-doworker.ts, agent registry, agent definitions, model adapter, hooks, and a BEFORE/AFTER migration from a bespoke take_notes tool to the auto-injected workspace__notes__* surface. Snippets below assume that file layout.

The workspace-specific lines are:

typescript
// agent.ts
export const NoteTakingAgent = defineAgent({
  name: 'note-taker',
  llmConfig: { model: yourModel },
  workspaces: {
    notes: {
      provider: { kind: 'cloudflare-filestore' },
      capabilities: { fs: true },
    },
  },
  systemPrompt: `You're a note-taking agent. Use workspace__notes__write_file to save notes to /notes/<slug>.md`,
});

// my-agent-server.ts (provider registration only — see the example for the rest)
workspaceProviders: (env, ctx) =>
  new Map([
    ['cloudflare-filestore', new CloudflareFileStoreWorkspaceProvider(ctx, env)],
  ]),

Worker entry — top-level wiring (round-5 D5)

The worker entry exports the DO class and routes incoming requests:

typescript
// worker.ts
import { createAgentServer } from '@helix-agents/runtime-cloudflare';
import { agentRegistry } from './agents.js';
import { CloudflareFileStoreWorkspaceProvider } from '@helix-agents/runtime-cloudflare';

interface Env {
  AGENTS: DurableObjectNamespace;
  FILES_R2?: R2Bucket;
  OPENAI_API_KEY: string;
}

// The DO class — exported by name to match the wrangler binding.
export const MyAgentServer = createAgentServer<Env>({
  llmAdapter: (env) => /* ... */,
  agents: agentRegistry,
  workspaceProviders: (env, ctx) =>
    new Map([
      ['cloudflare-filestore', new CloudflareFileStoreWorkspaceProvider(ctx, env)],
    ]),
});

// The Worker fetch handler routes to the DO. See the example for the
// full routing pattern (start, status, sse, etc.).
export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    const url = new URL(req.url);
    const sessionId = req.headers.get('x-session-id') ?? url.searchParams.get('sessionId');
    if (!sessionId) return new Response('missing sessionId', { status: 400 });
    const stub = env.AGENTS.get(env.AGENTS.idFromName(sessionId));
    return stub.fetch(req);
  },
};

The actual reference repo at examples/research-assistant-cloudflare-do has the full routing, hooks wiring, and authentication patterns — copy from there for production use.

Testing

The provider has no FakeFilestore, so your test strategy depends on what your test is asserting. Round-5 (D4) below clarifies the trade-offs.

Pure unit tests — testing your tool's logic

For unit tests of a custom tool that uses ctx.workspaces (not asserting filestore-specific behavior — just that the tool reads/writes the right paths and returns the right shape), use the round-5 (cluster C) helper createTestWorkspaceContext with InMemoryWorkspaceProvider:

typescript
import { createTestWorkspaceContext } from '@helix-agents/core';
import { InMemoryWorkspaceProvider } from '@helix-agents/workspace-memory';

const ctx = createTestWorkspaceContext({
  workspaces: {
    uploads: {
      provider: new InMemoryWorkspaceProvider(),
      capabilities: { fs: true },
    },
  },
});
const result = await myTool.execute(input, ctx);

Pros. Fast (sub-millisecond per tool execution). No setup, no Miniflare, no Docker. Runs in any vitest config.

Cons. Does NOT exercise the SQLite persistence path, the namespace hex-encoding, the @cloudflare/shell adapter, or the R2 spill behavior. If your tool depends on filestore-specific semantics (e.g. namespace boundary enforcement, R2 inline-threshold spill, persistence across DO hibernation), the in-memory test will not catch a regression there.

Drop-in agent tests — swap to in-memory under the same discriminator

For tests that exercise the agent flow end-to-end (LLM scripted, tools called, state checked) but where you don't care about SQLite persistence, register an InMemoryWorkspaceProvider under the 'cloudflare-filestore' discriminator key. The agent's provider: { kind: 'cloudflare-filestore' } config now resolves to the in-memory implementation:

typescript
import { InMemoryWorkspaceProvider } from '@helix-agents/workspace-memory';

const executor = new JSAgentExecutor(stateStore, streamManager, llmAdapter, {
  // The discriminator string is shared; the implementation is swapped.
  workspaceProviders: new Map([['cloudflare-filestore', new InMemoryWorkspaceProvider()]]),
});

This is the common pattern for "unit-test my agent against a fast in-memory store." The trade-off is the same as the pure-unit case above — no SQLite-specific coverage.

Integration tests — Miniflare + workerd against the real provider

For tests that exercise the REAL filestore implementation (SQLite-backed, namespace-encoded, optional R2 spill), use @cloudflare/vitest-pool-workers:

typescript
// vitest.cf.config.ts
import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config';

export default defineWorkersConfig({
  test: {
    include: ['src/__tests__/**/*.cf.test.ts'],
    poolOptions: {
      workers: {
        wrangler: { configPath: './wrangler.test.toml' },
        miniflare: {
          compatibilityDate: '2024-12-01',
          compatibilityFlags: ['nodejs_compat'],
        },
        // DO async ops cross the synchronous test boundary — disable
        // isolated storage to avoid the known cloudflare:test issue.
        isolatedStorage: false,
      },
    },
  },
});

Then write a test that drives the DO directly via the env binding:

typescript
import { describe, it, expect } from 'vitest';
import { env } from 'cloudflare:test';
import { MockLLMAdapter, FINISH_TOOL_NAME } from '@helix-agents/core';

const mockLLM = new MockLLMAdapter();
// ... canned mockLLM.addResponse(...) calls ...

it('writes a note and reads it back', async () => {
  const sessionId = `smoke-${Date.now()}`;
  const stub = env.AGENTS.get(env.AGENTS.idFromName(sessionId));
  await stub.fetch('http://do/start', {
    method: 'POST',
    headers: { 'x-partykit-room': sessionId, 'Content-Type': 'application/json' },
    body: JSON.stringify({ agentType: 'note-taker', sessionId, input: { message: 'hi' } }),
  });
  // ... poll status, assert messages ...
});

Two runnable references:

Decision summary

You want…Use
Fast tool-logic testcreateTestWorkspaceContext + InMemoryWorkspaceProvider
End-to-end agent test (in-memory ok)InMemoryWorkspaceProvider registered under 'cloudflare-filestore' discriminator
Filestore-specific behavior (SQLite, namespace, R2)@cloudflare/vitest-pool-workers + real provider

A FakeCloudflareFileStoreProvider exported from @helix-agents/runtime-cloudflare/testing is filed as a known follow-up; for now, prefer the integration test path for filestore-specific assertions.

Integration tests — Miniflare + workerd

For full-stack tests that exercise the real provider, use @cloudflare/vitest-pool-workers:

typescript
// vitest.cf.config.ts
import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config';

export default defineWorkersConfig({
  test: {
    include: ['src/__tests__/**/*.cf.test.ts'],
    poolOptions: {
      workers: {
        wrangler: { configPath: './wrangler.test.toml' },
        miniflare: {
          compatibilityDate: '2024-12-01',
          compatibilityFlags: ['nodejs_compat'],
        },
        // DO async ops cross the synchronous test boundary — disable
        // isolated storage to avoid the known cloudflare:test issue.
        isolatedStorage: false,
      },
    },
  },
});

Then write a test that drives the DO directly via the env binding:

typescript
import { describe, it, expect } from 'vitest';
import { env } from 'cloudflare:test';
import { MockLLMAdapter, FINISH_TOOL_NAME } from '@helix-agents/core';

const mockLLM = new MockLLMAdapter();
// ... canned mockLLM.addResponse(...) calls ...

it('writes a note and reads it back', async () => {
  const sessionId = `smoke-${Date.now()}`;
  const stub = env.AGENTS.get(env.AGENTS.idFromName(sessionId));
  await stub.fetch('http://do/start', {
    method: 'POST',
    headers: { 'x-partykit-room': sessionId, 'Content-Type': 'application/json' },
    body: JSON.stringify({ agentType: 'note-taker', sessionId, input: { message: 'hi' } }),
  });
  // ... poll status, assert messages ...
});

Two runnable references:

Limitations

  • fs only. No shell, code, or snapshot. Use Cloudflare Sandbox for those.
  • DO-local. Files are scoped to the agent's DO instance. They are not shared across DO instances; cross-DO workspace sharing is reserved for a future plan.
  • Workflows runtime not supported. Workspaces require the DO runtime path (createAgentServer) — Cloudflare Workflows currently has no native workspace providers and now fails fast at agent registration time, see the Workflows runtime page.

Capacity & performance

These are approximate ranges; benchmark for your workload.

DimensionApproximate rangeNotes
Namespace size~GB scale per namespaceBounded by the DO's SQLite limits + R2 spill for large files.
DO storage quota10 GB per DO instance (round-5 D13)Cloudflare-imposed cap on per-DO SQLite storage as of CF docs. Monitor namespace size; spill cold data to R2 or split sessions across DOs before approaching the cap.
Read latency (small files)p50 ~1ms, p99 ~5ms (in-DO)SQLite-backed; in-process within the agent DO. SQL SELECT is fast.
Read latency (R2-spilled files)p50 ~50ms, p99 ~200ms (round-5 D15)R2 GET round trip dominates. Files larger than inlineThreshold (default 1.5 MB) live in R2; reads pay the network round trip. Use the WorkspaceMetrics.observeToolLatencyMs histogram (cluster C round-4) to observe the bimodal distribution per workspace.
Concurrent namespaces per DO~10sEach namespace adds SQLite tables; many namespaces in one DO is unusual.
Cross-DO sharingNoneFiles are scoped to the agent DO instance.
Restart/hibernation costZero data lossSQLite is durable across DO hibernation.

For higher throughput or shared access, the v1 model is one-DO-per-session — re-architect at the agent layer.

Monitoring the 10 GB cap (round-5 D13). Cloudflare's SQLite-backed DO storage has a hard 10 GB ceiling per DO instance. The framework does not enforce or surface this cap — namespace growth is your responsibility. Recommended pattern: emit a periodic metric from a custom debug tool that queries the namespace size (SELECT SUM(LENGTH(content)) FROM workspace_files WHERE namespace = ?) and alerts when the size approaches 80% of the cap. For workloads dominated by large files, configure inlineThreshold lower (or use R2 spill aggressively) to keep SQLite compact and offload bulk to R2 (which scales independently).

Path scoping

The filestore is scoped to the configured namespace inside the DO's SQLite. @cloudflare/shell enforces the namespace boundary in its FileSystem layer; FS calls cannot reach data outside the namespace.

  • All FS methods operate within the namespace. There are no out-of-namespace paths to escape to — the namespace is encoded into the SQL queries the underlying adapter issues.
  • Path-shape validation (NUL bytes, oversize) is enforced inside the auto-injected tool layer; the same validation applies to custom tools using ws.fs!.readFile() (etc.) directly because the fs adapter is the same instance.
  • Path normalization: .. segments are normalized inside the path before the lookup key is computed.

If you need to share data across namespaces (e.g., a global dictionary file), expose it via a custom tool that reads R2 directly — do not try to escape the workspace boundary.

Restart behavior

When a DO restarts (deployment rollout, eviction, code reload), the workspace is re-attached lazily on the first agent operation that triggers provider.resolve() for each persisted ref. For a DO with N persisted workspace refs, the first operation post-restart triggers up to N parallel resolves.

Thundering-herd risk during rollout. A platform-wide rollout simultaneously restarts many DOs; each DO's first agent operation initiates its own resolve burst. Without coordination, this is a classic thundering-herd pattern.

Mitigation. Filestore resolves are very cheap (no container, no R2 round trip — just reattach to the SQLite namespace) so the herd amplitude is small. The Sandbox provider has the same shape but with much higher per-resolve cost; see its Restart behavior section. If you operate filestore alongside many other DOs and observe latency spikes during rollout, consider keeping maxConcurrentOpens configured to bound the per-DO burst.

Filed as follow-up: registry-side jitter on the first lazy resolve after recovery, to spread the burst across a few hundred ms.

Source

Internals

The following details are useful when debugging or contributing to the provider; integrators using the public API rarely need to look here.

Namespace handling

@cloudflare/shell requires the namespace to match /^[a-zA-Z][a-zA-Z0-9_]*$/. Most session IDs don't satisfy that (dashes, etc.), so the provider hex-encodes invalid session IDs with an s_ prefix automatically. The transformation is deterministic, so the same session ID always yields the same namespace across reads.

If you set namespace explicitly in config, it must satisfy the regex — the provider throws WorkspaceFailedError on invalid explicit namespaces (no silent rewriting).

Cross-hibernation behavior

Cloudflare Durable Objects can hibernate after periods of inactivity. When the DO wakes:

  1. The framework calls provider.resolve(ref) with the persisted WorkspaceRef.
  2. The ref payload contains the namespace + optional R2 binding name.
  3. resolve() reattaches by constructing a new Workspace with the same namespace against ctx.storage.sql — same data shows up because the SQLite tables persist.

No data loss. No recovery dance.

Released under the MIT License.