Workspace provides a durable virtual filesystem backed by SQLite and optional R2 large-file storage. It works with any Durable Object that has SQLite storage, D1 databases, or custom SQL backends.
Experimental — this feature may have breaking changes in future releases.
npm install @cloudflare/shellimport { Agent } from "agents";
import { Workspace } from "@cloudflare/shell";
class MyAgent extends Agent<Env> {
workspace = new Workspace({
sql: this.ctx.storage.sql,
name: () => this.name
});
async onMessage(conn, msg) {
await this.workspace.writeFile("/hello.txt", "world");
const content = await this.workspace.readFile("/hello.txt");
conn.send(content); // "world"
}
}Workspace accepts any SQL source via the sql option. The constructor auto-detects which type you pass.
Any Durable Object with SQLite storage — not just Agents:
// Inside any Durable Object
const workspace = new Workspace({ sql: ctx.storage.sql });// Using a D1 database binding
const workspace = new Workspace({ sql: env.MY_DB });Implement the SqlBackend interface for any other SQL source:
import type { SqlBackend } from "@cloudflare/shell";
const backend: SqlBackend = {
query(sql, ...params) {
// Return rows as an array of objects
return myDb.execute(sql, params);
},
run(sql, ...params) {
// Execute without returning rows
myDb.execute(sql, params);
}
};
const workspace = new Workspace({ sql: backend });query and run may return synchronously or return a Promise — Workspace handles both.
All options are passed as a single object to new Workspace(options).
| Option | Type | Default | Description |
|---|---|---|---|
sql |
SqlStorage | D1Database | SqlBackend |
required | SQL backend for file metadata and inline content |
namespace |
string |
"default" |
Table namespace for isolation |
r2 |
R2Bucket |
null |
R2 bucket for large files |
r2Prefix |
string |
name |
Key prefix for R2 objects |
inlineThreshold |
number |
1_500_000 |
Byte size above which files spill to R2 |
name |
string | () => string | undefined |
undefined |
Name for R2 prefix fallback and observability |
onChange |
(event: WorkspaceChangeEvent) => void |
undefined |
Callback fired on create, update, and delete |
In Durable Objects, this.name is not available at class field initialization time. Pass a function to defer evaluation:
class MyAgent extends Agent<Env> {
workspace = new Workspace({
sql: this.ctx.storage.sql,
name: () => this.name // evaluated when needed, not at construction
});
}await workspace.writeFile(
"/config.json",
'{"debug": true}',
"application/json"
);
const content = await workspace.readFile("/config.json"); // string | nullreadFile returns null for missing files. It throws EISDIR if the path is a directory.
await workspace.writeFileBytes("/image.png", pngBytes, "image/png");
const bytes = await workspace.readFileBytes("/image.png"); // Uint8Array | nullconst stream = await workspace.readFileStream("/large.bin");
await workspace.writeFileStream("/upload.bin", requestBody);writeFileStream collects all chunks before deciding inline vs R2 storage. The maximum stream size is 100 MB.
await workspace.appendFile("/log.txt", "new line\n");For inline UTF-8 files, this is an efficient SQL UPDATE content = content || ?. For R2-backed files, it reads, concatenates, and rewrites.
const deleted = await workspace.deleteFile("/old.txt"); // true | falseReturns false for missing files. Throws EISDIR for directories — use rm() instead.
await workspace.mkdir("/src/components", { recursive: true });
const entries = await workspace.readDir("/src"); // FileInfo[]
// Each entry: { path, name, type, mimeType, size, createdAt, updatedAt }
const matches = await workspace.glob("/src/**/*.ts"); // FileInfo[]await workspace.rm("/src", { recursive: true });
await workspace.rm("/maybe-missing", { force: true }); // no error if absentawait workspace.cp("/src", "/backup", { recursive: true });
await workspace.mv("/old.txt", "/new.txt");const stat = await workspace.stat("/file.txt"); // FileStat | null
// { path, name, type, mimeType, size, createdAt, updatedAt }
const exists = await workspace.exists("/file.txt"); // true for files and dirs
const isFile = await workspace.fileExists("/file.txt"); // true only for filesstat follows symlinks. Use lstat to get the symlink entry itself.
await workspace.symlink("/real.txt", "/link.txt");
const target = await workspace.readlink("/link.txt"); // "/real.txt"
const stat = await workspace.lstat("/link.txt"); // type: "symlink"Reading or writing through a symlink follows the target chain (up to 40 levels). Both absolute and relative targets are supported.
const diff = await workspace.diff("/a.txt", "/b.txt"); // unified diff string
const diff2 = await workspace.diffContent("/file.txt", newContent); // compare against stringReturns an empty string when the inputs are identical. Files larger than 10,000 lines are rejected.
const info = await workspace.getWorkspaceInfo();
// { fileCount, directoryCount, totalBytes, r2FileCount }Multiple Workspace instances can coexist on the same SQL source by using different namespaces. Each namespace gets its own table (cf_workspace_<namespace>):
const code = new Workspace({ sql: ctx.storage.sql, namespace: "code" });
const data = new Workspace({ sql: ctx.storage.sql, namespace: "data" });Namespace names must start with a letter and contain only alphanumeric characters or underscores.
Files below the inline threshold (default 1.5 MB) are stored directly in SQLite. Larger files store metadata in SQLite and content in R2:
const workspace = new Workspace({
sql: this.ctx.storage.sql,
r2: this.env.WORKSPACE_FILES,
name: () => this.name,
inlineThreshold: 2_000_000 // 2 MB
});R2 keys follow the pattern {name}/{namespace}{path}. If no r2Prefix is provided, name is used as the prefix.
When a file exceeds the threshold but no R2 bucket is configured, the file is stored inline with a console warning.
Pass an onChange callback to react to file changes in real time:
const workspace = new Workspace({
sql: this.ctx.storage.sql,
onChange: (event) => {
// event: { type: "create" | "update" | "delete", path, entryType }
this.broadcast(JSON.stringify(event));
}
});Workspace publishes structured events to the agents:workspace diagnostics channel via node:diagnostics_channel. Events are emitted for reads, writes, deletes, mkdir, rm, cp, and mv. Each event includes the workspace name, namespace, and operation-specific payload.
import { subscribe } from "node:diagnostics_channel";
subscribe("agents:workspace", (message) => {
console.log(message);
// { type: "workspace:write", name: "my-agent", payload: { path, size, storage, namespace }, timestamp }
});The channel is only active when subscribers exist — zero overhead otherwise.
Workspace integrates with @cloudflare/codemode to give sandboxed code access to the filesystem via a state object. Use stateTools() from @cloudflare/shell/workers:
import { Workspace } from "@cloudflare/shell";
import { stateTools } from "@cloudflare/shell/workers";
import { DynamicWorkerExecutor, resolveProvider } from "@cloudflare/codemode";
class MyAgent extends Agent<Env> {
workspace = new Workspace({
sql: this.ctx.storage.sql,
name: () => this.name
});
async run(code: string) {
const executor = new DynamicWorkerExecutor({ loader: this.env.LOADER });
return executor.execute(code, [
resolveProvider(stateTools(this.workspace))
]);
}
}Inside the sandbox, the state object exposes file operations, search/replace, JSON helpers, archive tools, and more. See Codemode for details.
import type {
SqlBackend,
SqlSource,
SqlParam,
WorkspaceOptions,
EntryType, // "file" | "directory" | "symlink"
FileInfo, // { path, name, type, mimeType, size, createdAt, updatedAt, target? }
FileStat, // same as FileInfo
WorkspaceChangeEvent, // { type, path, entryType }
WorkspaceChangeType // "create" | "update" | "delete"
} from "@cloudflare/shell";- Paths are normalized: leading
/is added if missing,..and.segments are resolved, duplicate slashes are collapsed - Maximum path length is 4,096 characters
writeFileandwriteFileBytesautomatically create parent directories
- Path traversal —
..segments are resolved during normalization, preventing directory escape - SQL injection — table names derive from the namespace, which is validated against
^[a-zA-Z][a-zA-Z0-9_]*$; all query parameters use parameterized queries - Symlink loops — resolution is capped at 40 levels, raising
ELOOPon cycles - Stream size —
writeFileStreamrejects streams exceeding 100 MB - Diff size —
diffanddiffContentreject files exceeding 10,000 lines