How to Implement a Custom Mount Provider for Cloudflare R2 or Other Storage Backends in Computer

Implement a custom mount provider by creating a function that returns an EagerMount object with a materialize(api) method, following the contract defined in packages/computer/src/mounts/types.ts.

The Computer library lets you mount external storage—R2, GitHub, or any custom backend—into a virtual workspace via a lightweight, type-driven provider pattern. A mount provider is simply a factory function that conforms to the EagerMount interface and streams files into the VFS using the MountWriteAPI during workspace initialization.

Understanding the Mount Provider Architecture

At its core, every mount provider must satisfy two contracts defined in the source:

  • MountBase (packages/computer/src/mounts/types.ts): Specifies kind (identifier string), mode ("read-only" or "read-write"), and optional maxBytes/maxEntries caps.
  • EagerMount: Extends MountBase with strategy: "eager" and a materialize(api: MountWriteAPI) async method.

The workspace invokes materialize() exactly once—the first cold start—then persists an indexed flag in _vfs_mounts to skip re-materialization on subsequent boots (packages/computer/src/mounts/registry.ts).

Step 1: Define the Provider Interface

Start by modeling your backend's minimal surface area. The R2 implementation demonstrates that you only need three operations: list objects, get object contents, and optionally handle pagination cursors.

// packages/computer/src/mounts/providers/custom.ts
import type { EagerMount, MountWriteAPI } from "../types.js";

export interface MyBackend {
  list(options?: { prefix?: string; cursor?: string; limit?: number }): Promise<{
    objects: Array<{ key: string; size: number }>;
    truncated: boolean;
    cursor?: string;
  }>;
  get(key: string): Promise<{ body: ReadableStream<Uint8Array>; size: number } | null>;
}

export interface MyBackendOptions {
  prefix?: string;
  listLimit?: number;
  concurrency?: number;
  maxBytes?: number;
  maxEntries?: number;
}

Step 2: Implement Pagination and Concurrency Helpers

The R2 provider in packages/computer/src/mounts/providers/r2.ts uses two reusable patterns: async generator pagination and bounded-concurrency fan-out.

Pagination helper streams all keys without buffering the entire dataset into memory:

function normalisePrefix(p?: string): string {
  if (!p) return "";
  return p.endsWith("/") ? p : `${p}/`;
}

async function* paginate(
  backend: MyBackend,
  prefix: string,
  limit: number,
): AsyncGenerator<{ key: string; size: number }> {
  let cursor: string | undefined;
  while (true) {
    const page = await backend.list({ prefix: prefix || undefined, cursor, limit });
    for (const obj of page.objects) yield obj;
    if (!page.truncated || !page.cursor) return;
    cursor = page.cursor;
  }
}

Bounded-concurrency runner limits simultaneous get calls to respect backend rate limits:

async function runBounded<T>(tasks: Array<() => Promise<T>>, concurrency: number): Promise<void> {
  if (concurrency < 1) concurrency = 1;
  let next = 0;
  const workers: Promise<void>[] = [];
  let firstError: unknown;

  for (let i = 0; i < Math.min(concurrency, tasks.length); i++) {
    workers.push(
      (async () => {
        while (true) {
          if (firstError !== undefined) return;
          const idx = next++;
          if (idx >= tasks.length) return;
          try {
            await tasks[idx]();
          } catch (e) {
            if (firstError === undefined) firstError = e;
            return;
          }
        }
      })(),
    );
  }

  await Promise.all(workers);
  if (firstError !== undefined) throw firstError;
}

Step 3: Build the EagerMount Factory

Combine these helpers into the final provider function. The materialize method receives api: MountWriteAPI with two critical methods: writeFile(path, stream) and mkdir(path).

export function MyBackendMount(backend: MyBackend, opts: MyBackendOptions = {}): EagerMount {
  const prefix = normalisePrefix(opts.prefix);
  const listLimit = opts.listLimit ?? 1000;
  const concurrency = opts.concurrency ?? 8;

  return {
    kind: "my-backend",
    mode: "read-only",
    strategy: "eager",
    maxBytes: opts.maxBytes,
    maxEntries: opts.maxEntries,

    async materialize(api: MountWriteAPI) {
      // Collect all keys via pagination.
      const keys: Array<{ key: string; size: number }> = [];
      for await (const obj of paginate(backend, prefix, listLimit)) {
        keys.push(obj);
      }

      // Fan-out get operations with bounded concurrency.
      const tasks = keys.map((entry) => async () => {
        const payload = await backend.get(entry.key);
        if (!payload) {
          throw new Error(`MyBackendMount: object vanished mid-materialize: ${entry.key}`);
        }
        const relKey = prefix ? entry.key.slice(prefix.length) : entry.key;
        if (!relKey) return; // Skip the prefix-object itself.

        await api.writeFile(`${api.root}/${relKey}`, payload.body);
      });

      await runBounded(tasks, concurrency);
    },
  };
}

Key implementation details from the R2 reference:

  • api.root contains the absolute VFS path (e.g., /workspace/my) prepopulated by the workspace.
  • Streaming: Pass the raw ReadableStream<Uint8Array> directly to writeFile—no buffering required.
  • Prefix stripping: Remove the configured prefix so data/file.txt mounts as /workspace/my/file.txt, not /workspace/my/data/file.txt.

Step 4: Export and Register the Provider

Add your provider to the public exports in packages/computer/src/index.ts:

export { MyBackendMount } from "./mounts/providers/custom.js";

Instantiate the workspace with a mounts map. Each key is an absolute VFS path; each value is the mount instance:

import { Workspace } from "@cloudflare/computer";
import { MyBackendMount } from "@cloudflare/computer/mounts/providers/custom";

const backend = env.MY_BACKEND as MyBackend;

const ws = await Workspace.create({
  sessionId: "user-123",
  db: env.DATABASE,
  mounts: {
    "/workspace/my": MyBackendMount(backend, {
      prefix: "data/",
      maxBytes: 50_000_000,
      concurrency: 16,
    })
  },
});

The Workspace constructor (in packages/computer/src/workspace.ts) iterates the mounts object, invokes materialize() for any unindexed mount, and records completion in _vfs_mounts.

R2-Specific Pattern for Cloudflare Workers

For Cloudflare R2 specifically, use the built-in R2Bucket provider in packages/computer/src/mounts/providers/r2.ts. The pattern is identical to custom providers—the R2 binding itself satisfies the MyBackend interface above:

import { R2Bucket } from "@cloudflare/computer/mounts/providers/r2";

const ws = await Workspace.create({
  mounts: {
    "/workspace/r2": R2Bucket(env.MY_BUCKET, {
      prefix: "uploads/",
      maxEntries: 10_000,
    })
  },
});

The R2 provider adds R2-specific optimizations: automatic HEAD request avoidance when size is known, and integration with R2's native ReadableStream implementation.

Key Source Files Reference

File Purpose
packages/computer/src/mounts/types.ts MountBase, EagerMount, MountWriteAPI definitions
packages/computer/src/mounts/providers/r2.ts Reference implementation showing pagination, streaming, concurrency
packages/computer/src/workspace.ts Workspace orchestration and mount lifecycle
packages/computer/src/mounts/registry.ts Indexed flag persistence in _vfs_mounts
examples/worker-shell/src/index.ts Real-world R2 mount example

Summary

  • Custom mount providers implement the EagerMount contract from packages/computer/src/mounts/types.ts.
  • The materialize(api) method receives MountWriteAPI to stream files via writeFile and create directories via mkdir.
  • Pagination and bounded concurrency are reusable patterns from the R2 reference implementation.
  • Registration happens through the mounts option in Workspace.create(), with automatic indexed-flag caching to skip re-materialization.
  • Export your provider in packages/computer/src/index.ts for consumer access.

Frequently Asked Questions

What is the difference between EagerMount and lazy mount strategies?

EagerMount with strategy: "eager" materializes all files during workspace cold start, blocking until the backend is fully indexed. Computer currently optimizes for eager mounts because they provide predictable, filesystem-like latency after initialization. Lazy or on-demand strategies are not yet implemented in the public API according to packages/computer/src/mounts/types.ts.

How do I enforce storage limits like maxBytes or maxEntries?

The workspace enforces caps automatically when the mount specifies maxBytes or maxEntries in its returned object. During materialize(), the workspace tracks cumulative bytes and entry count; exceeding either limit aborts materialization and throws. Your provider should not pre-filter—return the full dataset and let the workspace apply limits consistently.

Can I implement write support for my custom backend?

Change mode to "read-write" and implement a MountReadWriteAPI, though the current source in packages/computer/src/mounts/types.ts shows mode as primarily informational. Full write support requires additional methods beyond materialize, such as sync() or change watchers. The R2 provider currently uses "read-only"; inspect future releases for expanded MountWriteAPI surface area.

Why does materialization only run once per mount path?

The workspace persists an indexed flag in the _vfs_mounts SQLite table via packages/computer/src/mounts/registry.ts. On subsequent boots, the workspace checks this flag and skips materialize() if set. To force re-materialization (e.g., after backend changes), clear the mount's entry from _vfs_mounts or use a different mount path.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →