Adding a Persistent Virtual Filesystem to Cloudflare Durable Objects with @cloudflare/computer

How to build a persistent file system for AI agents inside Cloudflare Durable Objects (DO) and let them execute commands directly. @cloudflare/computer wraps a Virtual File System (VFS) on top of the DO’s SQLite storage and plugs into multiple code and command execution backends. The toolkit is currently in preview, APIs may shift, and it works well for prototyping and exploration.

Installation and Minimal Setup for File Storage Only

How to give a DO file read and write capabilities that survive restarts as quickly as possible. Run npm install @cloudflare/computer. Your Worker requires the nodejs_compat compatibility flag. If you only need file system operations without command execution, use withWorkspace to mix into your DO class.

In the code below, withWorkspace takes a native DO class and provides a storage configuration pointing to the DO’s own SQLite storage.

import { withWorkspace, getWorkspace } from "@cloudflare/computer";
import { DurableObject } from "cloudflare:workers";

export class Agent extends withWorkspace(
  class extends DurableObject<Env> {},
  (self) => ({ storage: self.ctx.storage }),
) {}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const id = env.Agent.idFromName("user-123");
    using ws = await getWorkspace(env.Agent.get(id));

    await ws.fs.writeFile("/notes.md", "- [ ] ship it\n");
    const notes = await ws.fs.readFile("/notes.md", "utf8");

    return new Response(notes);
  },
} satisfies ExportedHandler<Env>;

Configure wrangler.jsonc with the DO binding and declare the SQLite migration.

{
  "compatibility_flags": ["nodejs_compat"],
  "durable_objects": {
    "bindings": [{ "name": "Agent", "class_name": "Agent" }]
  },
  "migrations": [
    { "tag": "v1", "new_sqlite_classes": ["Agent"] }
  ]
}

This VFS handles roughly 10 GB of data per workspace. Keep in mind that the container backend holds the file system in memory. Keep workspaces at an agent scale and avoid dropping massive monorepos into them.

Executing Commands and Code Inside the Workspace

How to run shell commands or code within this file system. workspace.runtime.exec() serves as the single execution entry point. It supports three backends. Choose based on your dependency on a full Linux environment.

Backend Import Path Executes Requirements
Container @cloudflare/computer/backends/container Shell commands in a full Linux userland Requires a Cloudflare Container running computerd
Worker shell @cloudflare/computer/backends/worker-shell just-bash inside a Dynamic Worker Requires a Worker Loader binding and the experimental flag
Worker JavaScript @cloudflare/computer/backends/worker-javascript ECMAScript modules in a fresh Dynamic Worker Requires a Worker Loader binding and the experimental flag

The worker-shell backend is the fastest way to get exec running without a Docker container. The tradeoff involves adding the experimental flag and a worker_loaders binding to wrangler.jsonc.

{
  "compatibility_flags": ["nodejs_compat", "experimental"],
  "worker_loaders": [{ "binding": "LOADER" }]
}

In your code, initialize WorkerShellBackend and pass in the loader and workspace bindings.

import { withWorkspace, getWorkspace } from "@cloudflare/computer";
import { WorkerShellBackend } from "@cloudflare/computer/backends/worker-shell";
import curlModules from "@cloudflare/computer/shell/curl";
import { DurableObject } from "cloudflare:workers";

export class Agent extends withWorkspace(
  class extends DurableObject<Env> {},
  (self) => ({
    storage: self.ctx.storage,
    backends: [
      new WorkerShellBackend({
        loader: self.env.LOADER,
        workspace: { binding: "Agent", id: self.ctx.id.toString() },
        ctx: self.ctx,
        commands: [curlModules],
      }),
    ],
  }),
) {}

The worker shell splits commands into feature groups. Commands like curl, sqlite, jq, and python are available on demand. Only the modules you mount in the commands option are reachable in the execution environment, and unimported groups get dropped by the bundler. All file system operations forward back to the same Durable Object, eliminating a second store and sync round trip.

The container backend provides real Linux binaries and a full network environment, but it cold-starts more slowly. It maintains its own SQLite VFS and syncs state with the main DO over a capnweb WebSocket. Container file access routes through FUSE, so heavy I/O operations like installing a massive node_modules or extracting large tarballs will be slower than a native disk.

Advanced File System Usage and R2 Mounts

How to handle binary streams and mount external read-only data for the workspace. The workspace.fs interface is asynchronous and requires absolute paths. Strings default to UTF-8 encoding. For binary data, pass a Uint8Array or a ReadableStream directly.

// Write a string, bytes, or a stream
await ws.fs.writeFile("/notes/todo.md", "- [ ] ship it\n");
await ws.fs.writeFile("/data/blob.bin", new Uint8Array([1, 2, 3]));
await ws.fs.writeFile("/uploads/big.csv", request.body!);

// Read back as a string or a stream
const todo = await ws.fs.readFile("/notes/todo.md", "utf8");
const stream = await ws.fs.readFile("/uploads/big.csv");
return new Response(stream);

// Directories and search
await ws.fs.mkdir("/notes/daily", { recursive: true });
await ws.fs.rm("/notes/daily", { recursive: true });
const hits = await ws.fs.grep("TODO", "/", { ignoreCase: true });

To pre-load static files into the workspace, mount an R2 bucket. Files under the mount point are strictly read-only, and write attempts throw an EROFS error.

import { R2Bucket } from "@cloudflare/computer";

new Workspace({
  storage: ctx.storage,
  mounts: { "/workspace/r2": R2Bucket(env.Bucket) },
});

Equipping AI Models with Tools and Git Access

How to feed this file system directly to the Vercel AI SDK. @cloudflare/computer/tools packages ready-made AI SDK tools. The default set includes read, write, edit, and ls. You can add exec and publish through configuration. Set byte and line limits for the read tool.

When configuring the exec tool, write a description for each backend. The model reads this plain language description to decide where to route a command.

import { createAITools } from "@cloudflare/computer/tools";

const tools = createAITools({
  workspace,
  read: { maxBytes: 32 * 1024, maxLines: 800 },
  shell: {
    defaultBackend: "shell",
    backends: {
      shell: { description: "Fast Worker shell with built-in text commands." },
      container: { description: "Full Linux userland in a Cloudflare Container." },
    },
  },
});

Git capabilities are optional as well. Through @cloudflare/computer/git, you can clone, add, and commit directly on the local SQLite VFS. It uses isomorphic-git under the hood, swapping its pako dependency for the Workers platform node:zlib implementation. It loads lazily and does not bloat the main package graph unless explicitly enabled.

The Gotcha: DO Stubs Are Not Garbage Collected

Why long sessions leak memory and how to prevent it. A Worker accesses the Workspace on the DO through a stub. The RPC layer in this toolkit does not automatically garbage collect stubs. During long-lived sessions or high-frequency exec workloads, undisposed stubs accumulate on the peer until the session terminates.

The correct approach involves using the using keyword to declare return values. Make using ws = await getWorkspace(...) and using run = await ws.runtime.exec(...) a muscle memory.

Properties attached to the parent object (like ws.fs, ws.runtime, ws.git) ride along with the parent. Methods that return pure values (such as readFile returning a string, stat, readdir) carry no stubs and require no disposal.

If you suspect a leak, set the environment variable CAPNWEB_TRACK_STUBS=1. Read stubSnapshot() from @cloudflare/computer-rpc/debug or hit the GET /__computerd/stubs endpoint on a computerd instance.

Multiple Backend Routing and Sync Retries

Whether a workspace can mount multiple backends and route between them. Pass multiple backend instances into the Workspace initialization and assign a unique id to each. Calling exec without specifying a backend hits the first one. Passing { backend: "sandbox" } routes the call to your designated backend.

const ws = new Workspace({
  storage: ctx.storage,
  backends: [
    new WorkerShellBackend({ id: "shell", loader: env.LOADER }),
    new CloudflareContainerBackend({ id: "sandbox", container: () => this }),
  ],
});

const grep = await ws.runtime.exec("grep -r TODO /workspace");
const build = await ws.runtime.exec("npm test", { backend: "sandbox" });

Backends connect lazily, dialing in only on the first exec or ready call. Each backend maintains an independent sync cursor, so activity on one never disturbs the other.

Sometimes a command finishes on the backend, but the sync operation to pull files back fails. The result exposes sync: { status: "pending" }. Configure a SyncRetryScheduler on the Workspace to persist one coalesced retry per backend. Then, call workspace.retryPendingSync(backend) manually from your DO alarm. Retries use bounded exponential backoff and return "exhausted" after the configured maximum attempts. The library does not own your DO alarm, leaving the trigger entirely up to you.

Quick Checklist and FAQ

Operation Checklist

  1. Run npm install @cloudflare/computer
  2. Enable the nodejs_compat flag in wrangler.jsonc
  3. Add the experimental flag and configure worker_loaders if using worker-shell or worker-javascript backends
  4. Wrap your DO class with withWorkspace and pass self.ctx.storage in the callback
  5. Import shell command modules on demand and pass them to the backend constructor
  6. Enforce the using keyword for return values of getWorkspace and ws.runtime.exec
  7. Set CAPNWEB_TRACK_STUBS=1 when troubleshooting leaks in long-lived sessions

Frequently Asked Questions

Can this library be used in production environments right now?
No. The official documentation marks it as a preview. APIs are unstable, the design may change, and it suits experiments and prototypes only.

What is the maximum file capacity?
Around 10 GB per workspace. It shares storage with the DO, and the container backend keeps the VFS in memory.

How do I reduce the bundle size when using the worker-shell backend?
Import by feature group. If you only need network requests, import @cloudflare/computer/shell/curl and pass it to the commands option. Unimported modules get tree-shaken by the bundler.

Can I modify files mounted from an R2 bucket?
No. Files under the mount point are strictly read-only, and write attempts throw an EROFS error.

Which objects require the using keyword for disposal?
The client object returned by getWorkspace(...) and the run handle returned by ws.runtime.exec(...). Sub-objects like ws.fs and functions returning pure values need no disposal.

Does it automatically retry after a sync failure?
The library does not retry automatically. You must configure a SyncRetryScheduler and manually call workspace.retryPendingSync from your DO alarm.

How do I share files out of the workspace?
Use createAssets(...).share from @cloudflare/computer/assets to upload a file to R2 and generate a presigned URL, or use @cloudflare/computer/artifacts to interact with the Cloudflare Artifacts binding.