A persistent, SQLite-backed virtual filesystem for Durable Objects, with pluggable command and code execution. Built for agents that need a small, portable working directory and the tools to read, write, and run things in it.
Important
PREVIEW ONLY. This package is provided as a preview for feedback only. APIs are unstable and the design is subject to change. Suitable for experiments, exploration, and prototypes. It is NOT suitable for production use at this time.
- A filesystem in your Durable Object.
workspace.fslooks likenode:fs/promisesβreadFile,writeFile,mkdir,readdir,rm,grepβ and is durable across DO restarts, backed by the DO's own SQLite storage. - One execution surface, several backends.
workspace.runtime.exec()runs a shell command or an ECMAScript module. Pick a full Linux container, a fast in-Worker shell, or an isolated JavaScript runtime, all against the same files. - Batteries for agents. Ready-made AI SDK
tools (
read,write,edit,ls,exec), a git client, R2-backed read-only mounts, and helpers for publishing files.
The Workspace can also run with no execution backend at all, giving you just the filesystem.
- ~10 GB per workspace (it shares storage with the DO).
- The container-side filesystem is held in memory. Aim for agent-scale workspaces, not full monorepos.
- Container access goes through FUSE, so heavy I/O (large
node_modulesinstalls, big tarball extractions) is slower than a native disk. Seedocs/19_performance.md.
npm install @cloudflare/computerYour Worker needs the nodejs_compat compatibility flag. The
worker-shell and worker-javascript backends additionally need the
experimental flag and a Worker Loader binding. Each backend has its
own binding requirements β see Choosing a backend.
Optional peer dependencies, installed only if you use the matching
feature: ai and zod (for @cloudflare/computer/tools),
@platformatic/vfs (for the Node-side VFS provider).
The smallest useful thing is a filesystem with no execution backend. Add
withWorkspace to a Durable Object and you have durable files:
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>;wrangler.jsonc:
To run commands against those files, add an execution backend. The
worker-shell backend needs no container and no Docker, so it's the
quickest way to get exec working:
import { withWorkspace, getWorkspace } from "@cloudflare/computer";
import { WorkerShellBackend } from "@cloudflare/computer/backends/worker-shell";
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,
}),
],
}),
) {}Add the loader binding and the experimental flag to wrangler.jsonc:
{
"compatibility_flags": ["nodejs_compat", "experimental"],
"worker_loaders": [{ "binding": "LOADER" }]
}Now exec runs against the same files your fs calls wrote:
using ws = await getWorkspace(env.Agent.get(id));
await ws.fs.writeFile("/hello.txt", "world");
using run = await ws.runtime.exec("cat /hello.txt");
const { stdout, exitCode } = await run.result();For a full, runnable version of this see
examples/worker-shell.
workspace.fs is async, uses absolute paths, and is durable across DO
restarts. Strings default to UTF-8; pass a Uint8Array or a
ReadableStream for binary content.
// Write a string, bytes, or a stream straight to disk.
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 as 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.
await ws.fs.mkdir("/notes/daily", { recursive: true });
for (const entry of await ws.fs.readdir("/notes")) {
console.log(entry.isDirectory ? `d ${entry.name}` : `f ${entry.name}`);
}
// Remove and search.
await ws.fs.rm("/notes/daily", { recursive: true });
const hits = await ws.fs.grep("TODO", "/", { ignoreCase: true });See docs/04_filesystem_interface.md
for the full surface.
Pre-fill part of the tree from an R2 bucket. Files under the mount point
are read-only; writes reject with EROFS.
import { R2Bucket } from "@cloudflare/computer";
new Workspace({
storage: ctx.storage,
mounts: { "/workspace/r2": R2Bucket(env.Bucket) },
});workspace.runtime.exec(source, options) is the single execution entry
point. What source means depends on the backend: a shell command for
the container and worker-shell backends, an ECMAScript module for the
worker-javascript backend.
using run = await ws.runtime.exec("ls -la /workspace", { encoding: "utf8" });
const { stdout, stderr, exitCode } = await run.result();The handle is also a ReadableStream of live events, so you can forward
output as it happens β for example, as Server-Sent Events:
async fetch(request: Request) {
const run = await ws.runtime.exec("npm test", { encoding: "utf8" });
const sse = run.pipeThrough(
new TransformStream({
transform(event, controller) {
const frame = `event: ${event.name}\ndata: ${JSON.stringify(event.value)}\n\n`;
controller.enqueue(new TextEncoder().encode(frame));
},
}),
);
return new Response(sse, {
headers: { "content-type": "text/event-stream", "cache-control": "no-cache" },
});
}Alongside exec, the runtime exposes getExec, killExec, and
disposeExec. See
docs/05_runtime_interface.md.
| Backend | Import | Runs | Needs |
|---|---|---|---|
| Container | @cloudflare/computer/backends/container |
Shell commands in full Linux userland (real binaries, npm, node, network) |
A Cloudflare Container running computerd |
| Worker shell | @cloudflare/computer/backends/worker-shell |
Shell commands via just-bash in a Dynamic Worker | A Worker Loader binding; experimental flag |
| Worker JavaScript | @cloudflare/computer/backends/worker-javascript |
ECMAScript modules in a fresh Dynamic Worker | A Worker Loader binding; experimental flag |
- Container cold-starts more slowly but gives you a real Linux
environment. The container owns its own SQLite-backed VFS and this
package syncs the two stores across a capnweb WebSocket. See
docs/07_injected_service.mdfor the container image, andexamples/container. - Worker shell is fast and needs no container. Every filesystem
operation forwards back to the same Durable Object, so there's no
second store and no sync round trip. See
docs/12_worker_backend.mdandexamples/worker-shell. - Worker JavaScript evaluates a module with structured
input/results, durable relative imports, configured libraries,
Workspace-backed
node:fs/promises, and trustedws:git/ws:artifactsmodules. It runs afterruntime.exec()returns, so passwaitUntil: ctx.waitUntil.bind(ctx)toWorkspace; the backend refuses to connect without it. Seedocs/17_isolate_javascript.mdandexamples/worker-javascript.
You can register several backends on one Workspace and route each call to a named one β see Multiple backends.
@cloudflare/computer/tools ships AI SDK tools that wrap the Workspace
surfaces, ready to hand to generateText, streamText, or an agent
framework's getTools(). The default set is read, write, edit,
and ls; exec and publish are added when you configure them.
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." },
},
},
});The model reads each backend's description when deciding where a
command should run, so write them in plain language. See
docs/09_tool_interface.md.
workspace.git is an opt-in typed git client backed by
isomorphic-git,
operating directly on the local SQLite VFS β no backend or shell
required. Enable it by passing createGitClient() from
@cloudflare/computer/git:
import { Workspace } from "@cloudflare/computer";
import { createGitClient } from "@cloudflare/computer/git";
const ws = new Workspace({
storage: ctx.storage,
git: createGitClient(),
defaultGitIdentity: { name: "Agent", email: "agent@example.test" },
});
await ws.git.clone({ url: "https://github.com/example/repo.git" });
await ws.fs.writeFile("/notes.md", "hello");
await ws.git.add({ paths: ["notes.md"] });
await ws.git.commit({ message: "add notes" });The git subpath bundles isomorphic-git lazily and swaps its pako
dependency for the Workers node:zlib implementation, so the default
package graph stays free of git. There's also an argv entry point
(workspace.git.cli({ argv })), and when git is configured the
worker-shell backend exposes a built-in git command. See
docs/13_git_interface.md.
Two ways to get a file out of the workspace and into the world:
- Assets (
@cloudflare/computer/assets):createAssets(...).shareuploads a workspace file to R2 and returns a presigned URL. Attach it throughWorkspaceOptions.assetsto expose an in-shellassets publish <path> [<expiry>]command. Seedocs/14_assets_interface.md. - Artifacts (
@cloudflare/computer/artifacts):createArtifact(binding, sessionId)is a session-scoped facade over the Cloudflare Artifacts binding. Every repository name is implicitly prefixed with the session id, so one namespace hosts many isolated sessions.
import { createArtifact } from "@cloudflare/computer/artifacts";
const artifacts = createArtifact(env.ARTIFACTS, agentId);
const repo = await artifacts.create("build-cache", { description: "CI artifacts" });
const token = await artifacts.createToken("build-cache", "read", 3600);
const mine = await artifacts.list(); // only this session's reposArtifacts also offers an argv CLI (artifacts.cli({ argv })), and when
Workspace is configured with an Artifacts binding the worker-shell
backend exposes an artifacts command. See
docs/15_artifacts_interface.md.
The Durable Object owns the Workspace; a Worker reaches it through a
stub. withWorkspace installs the plumbing, and getWorkspace(stub)
returns a client:
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", "hello");
using run = await ws.runtime.exec("ls /workspace");
const { exitCode, stdout } = await run.result();
return new Response(stdout, { status: exitCode === 0 ? 200 : 500 });
},
} satisfies ExportedHandler<Env>;The one gotcha worth internalizing: the RPC layer does not garbage-collect remote stubs. On long-lived sessions undisposed stubs accumulate on the peer until the session ends. The rules are short:
usingthe value fromgetWorkspace(...).usingthe handle fromws.runtime.exec(...).- Don't worry about
ws.fs,ws.runtime, orws.gitβ they ride with the parent. - Pure-value returns (
readFileas a string,stat,readdir,git.cli({...})) carry no stubs; nothing to dispose.
Short-lived single-shot Workers tear the session down with the request,
so this matters most on long-lived isolates that keep grabbing fresh
stubs or on busy exec workloads. The full contract is in
docs/11_lifecycle.md.
To hunt leaks, set CAPNWEB_TRACK_STUBS=1 and read stubSnapshot()
from @cloudflare/computer-rpc/debug, or hit GET /__computerd/stubs
on a computerd instance.
| Entrypoint | Purpose |
|---|---|
@cloudflare/computer |
The Workspace facade, workspace.runtime, stub types, the R2 mount, and proxy classes. |
@cloudflare/computer/backends/container |
CloudflareContainerBackend and withWorkspaceContainer. Pulls in the computerd / capnweb sync plumbing. |
@cloudflare/computer/backends/worker-shell |
WorkerShellBackend and the bundled just-bash runtime. |
@cloudflare/computer/backends/worker-javascript |
WorkerJavaScriptBackend, configured libraries, durable imports, node:fs/promises, and trusted ws:git / ws:artifacts. |
@cloudflare/computer/tools |
AI SDK tools for agents: read, write, edit, ls, optional exec and publish. |
@cloudflare/computer/git |
Opt-in isomorphic-git glue for checkouts inside the workspace. |
@cloudflare/computer/assets |
createAssets β share a workspace file to R2 as a presigned URL. |
@cloudflare/computer/artifacts |
createArtifact and its CLI, a session-scoped facade over the Cloudflare Artifacts binding. |
@cloudflare/computer/observe/cloudflare |
Cloudflare-runtime adapter for the observability hook. |
A consumer that only uses the container backend never imports the worker
subpaths, so unused backend payloads tree-shake away. Wire types shared
with the in-container service live in the sibling package
@cloudflare/computer-rpc.
A Workspace can carry more than one backend, each registered under a
stable selector id (defaulting to "worker-shell",
"container-shell", or "worker-javascript"). runtime.exec uses the
first backend unless the caller names one:
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"); // default: "shell"
const build = await ws.runtime.exec("npm test", { backend: "sandbox" });Backends connect lazily β the first exec, push, pull, or
ready(id) for an id dials it. ready({ all: true }) pre-warms every
backend, which is handy from an agent's onStart. Per-backend sync
cursors are independent, so activity on one never disturbs the other.
A backend that accepts a structured input and returns a structured
value declares callable: true (the worker-javascript backend does).
Passing input to a non-callable backend is a clear error rather than a
silent drop.
withWorkspace is the shortcut. You can also construct a Workspace
directly and expose it through a stub yourself:
const ws = new Workspace({ storage: ctx.storage, backends: [/* ... */] });
await ws.ready();
const stub = ws.stub(); // crosses the Workers-RPC boundaryWhen assigning a workspace to a Think agent's workspace, pass
useThink: true so Think's compatibility methods are added alongside
workspace.fs and workspace.runtime.
A command can change backend files and then have its post-command pull
fail; the result exposes sync: { status: "pending", ... }. Configure a
SyncRetryScheduler on Workspace to persist one coalesced retry per
backend, then call workspace.retryPendingSync(backend) from your DO's
alarm. Retries use bounded exponential backoff and return "exhausted"
after the configured maximum. The library does not own your DO's alarm.
See SyncRetryScheduler, SyncRetryIntent, and SyncRetryOptions in
the package exports.
Pass an observer to the Workspace constructor to receive one span
per documented operation (workspace.connect, workspace.sync.push,
workspace.sync.pull, workspace.runtime.exec.spawn,
workspace.fs.<op>). The span(name, attributes, run) shape fits the
Cloudflare runtime's ctx.tracing, OpenTelemetry, or a test recorder;
attribute values are restricted to boolean | number | string. The
default is a zero-cost no-op, so there's no overhead unless you opt in.
An adapter for the Cloudflare runtime lives at
@cloudflare/computer/observe/cloudflare.
examples/worker-shellβ the worker-shell backend behind awrite/read/execHTTP surface. No container.examples/worker-javascriptβ the same shape, running ECMAScript modules instead of shell commands.examples/containerβ the container backend runningcomputerd.examples/thinkβ a chat agent that uses the workspace as its working directory.examples/tutorialβ a step-by-step build: write a markdown card on the host, render it to PDF withpandocin the container.
The design specification lives under docs/.
Start with 01. VFS,
04. Filesystem Interface, and
05. Runtime Interface. It is
forward-looking β read it for intent, not as a description of the code
today.
MIT. See LICENSE.
{ "compatibility_flags": ["nodejs_compat"], "durable_objects": { "bindings": [{ "name": "Agent", "class_name": "Agent" }] }, "migrations": [ { "tag": "v1", "new_sqlite_classes": ["Agent"] } ] }