# `@cloudflare/computer`

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]
> The `@cloudflare/computer` package should be considered suitable for
> production use. The package contains work-in-progress and experimental
> components such as the `ContainerBackend`. These should be considered
> unsuitable for production use. See the [reference below](#status) for more details.

## Status

Stable means suitable for production use. Experimental APIs are
provided for feedback: they may change or go away in any release, so
use them for experiments and prototypes only. Deprecated APIs will be
removed in a future release; each one names its replacement.

| Component | Entrypoint | Status |
| --- | --- | --- |
| `Workspace`, `withWorkspace`, `getWorkspace`, `workspace.fs`, `workspace.runtime`, and the stub types | `@cloudflare/computer` | Stable |
| The SQLite filesystem layer (`@cloudflare/dofs`, bundled into this package), including `SQLiteWorkspaceProvider` | `@cloudflare/computer` | Stable |
| `WorkerJavaScriptBackend` | `@cloudflare/computer/backends/worker-javascript` | Stable |
| Read-only mounts (`R2Bucket`, `WorkerBundle`) and the Vite plugin | `@cloudflare/computer`, `@cloudflare/computer/vite` | Stable |
| Agent tools | `@cloudflare/computer/tools`, `@cloudflare/computer/tools/*` | Stable |
| Host modules for isolate JavaScript: `ws:git`, `ws:artifacts`, `ws:assets`, `ws:tools` | `@cloudflare/computer/modules/git`, `modules/artifacts`, `modules/assets`, `modules/tools` | Stable |
| Git | `@cloudflare/computer/git` | Stable |
| Assets and Artifacts | `@cloudflare/computer/assets`, `@cloudflare/computer/artifacts` | Stable |
| Observability hook and its Cloudflare adapter | `@cloudflare/computer`, `@cloudflare/computer/observe/cloudflare` | Stable |
| `ContainerBackend` and `withWorkspaceContainer` | `@cloudflare/computer/backends/container` | Experimental |
| `LegacyContainerBackend` and `withLegacyWorkspaceContainer` | `@cloudflare/computer/backends/container-legacy` | Deprecated: use `ContainerBackend` |
| Resuming interrupted isolate JavaScript: `onInterrupt: "restart"` and `"replay"`, `workspace.runtime.recover()`, `unsafeToRepeat()`, and waiting for answers with `ctx.interrupt()`, `pending()` and `respond()` | `@cloudflare/computer`, `@cloudflare/computer/backends/worker-javascript` | Experimental |
| `WorkerShellBackend` and the shell command groups | `@cloudflare/computer/backends/worker-shell`, `@cloudflare/computer/shell/*` | Experimental |
| The `ws:container` host module | `@cloudflare/computer/modules/container` | Experimental |
| `workspace.push()`, `workspace.pull()`, and the sync protocol between the durable object and `computerd` | `@cloudflare/computer` | Experimental |
| The `computerd` daemon and its container image | `ghcr.io/cloudflare/computer-computerd-linux-x64` | Experimental |
| The RPC wire protocol between the durable object and `computerd` (`@cloudflare/computer-rpc`) | Internal | Experimental |

A stable surface that runs work on a backend, such as the agent tools'
`exec` tool, is only as stable as the backend it runs on.

The sync building blocks inside `@cloudflare/dofs` (`applyChanges`,
`fetchChanges`, watermarks, and the rest) serve only the sync protocol,
so they share its experimental status. The rest of `@cloudflare/dofs`
is stable.

## What you get

- **A filesystem in your Durable Object.** `workspace.fs` looks like
  `node: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](https://github.com/vercel/ai)
  tools (`read`, `ls`, `find`, `grep`, `write`, `edit`, `delete`, and optional
  `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.

### Limits

- ~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_modules`
  installs, big tarball extractions) is slower than a native disk. See
  [`docs/19_performance.md`](../../docs/19_performance.md).

## Installation

```sh
npm install @cloudflare/computer
```

Your Worker needs the `nodejs_compat` compatibility flag. The
worker-shell and worker-javascript backends also need a Worker Loader
binding, and the worker-shell backend needs the `experimental` flag. Each backend has its
own binding requirements — see [Choosing a backend](#choosing-a-backend).

Optional peer dependencies, installed only if you use the matching
feature: `just-bash` for `@cloudflare/computer/backends/worker-shell`,
`zod` for every tools entry point, `ai` for
`@cloudflare/computer/tools` and `@cloudflare/computer/tools/ai-sdk`, and
`@platformatic/vfs` for the Node-side VFS provider. The pi and TanStack
AI entry points need nothing beyond `zod`; your agent brings its own
library.

## Quick start

The smallest useful thing is a filesystem with no execution backend. Add
`withWorkspace` to a Durable Object and you have durable files:

```ts
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`:

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

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. It is experimental; for a stable
backend, use the worker-javascript backend, shown in
[`examples/worker-javascript`](../../examples/worker-javascript):

```ts
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],
      }),
    ],
  }),
) {}
```

Add the loader binding and the `experimental` flag to `wrangler.jsonc`:

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

The worker shell ships as feature groups: an always-on core plus
one optional group per command at
`@cloudflare/computer/shell/<feature>`. Import the groups you want
and pass them to `WorkerShellBackend`'s `commands` option; a group you
never import is unreachable in your bundle and the bundler drops
it. The optional groups are `curl`, `html-to-markdown`, `python`,
`sqlite`, `js-exec`, `yq`, `file`, `xan`, and `jq`. `curl` runs on
the isolate's global `fetch` (no `undici` in the bundle); egress
stays governed by the Dynamic Worker's `globalOutbound`.

Now `exec` runs against the same files your `fs` calls wrote:

```ts
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`](../../examples/worker-shell).

## The filesystem

`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.

```ts
// 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`](../../docs/04_filesystem_interface.md)
for the full surface.

### Read-only mounts

Pre-fill part of the tree from an R2 bucket. Files under the mount point
are read-only; writes reject with `EROFS`.

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

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

`WorkerBundle` copies files that ship inside your Worker, such as skills
or templates. The copy is read-only, and it is replaced automatically
when a deploy changes the files.

With Vite, add `computerBundle()` from `@cloudflare/computer/vite` to
your plugins and import the directory. This works in `vite dev` too,
with edits picked up as you go. Text files only:

```ts
// vite.config.ts: plugins: [cloudflare(), computerBundle()]
import { WorkerBundle } from "@cloudflare/computer";
import skills from "computer:bundle/src/skills";

new Workspace({
  storage: ctx.storage,
  mounts: { "/workspace/.agents/skills": WorkerBundle(skills) },
});
```

With wrangler, pass a path instead. It's read from the Worker upload
under `/bundle`, so each file has to be uploaded under its own path:

```jsonc
// wrangler.jsonc, with "main": "src/index.ts"
"find_additional_modules": true,
"rules": [{ "type": "Data", "globs": ["skills/**/*"], "fallthrough": true }]
```

```ts
mounts: { "/workspace/.agents/skills": WorkerBundle("skills") }
```

See [Mount Interface](../../docs/06_mount_interface.md#workerbundlesource-options)
for the options, types, and how refresh works.

## Running commands and code

`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.

```ts
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:

```ts
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`](../../docs/05_runtime_interface.md).

### Choosing a backend

| Backend | Status | Import | Runs | Needs |
| --- | --- | --- | --- | --- |
| **Container** | Experimental | `@cloudflare/computer/backends/container` | Shell commands in full Linux userland (real binaries, `npm`, `node`, network) | A Cloudflare Container running `computerd`, scheduled by the durable object (`scheduling_policy: "durable_object"`) |
| **Container (legacy)** | Deprecated | `@cloudflare/computer/backends/container-legacy` | The same | A Cloudflare Container the platform schedules and sizes from the `containers` block |
| **Worker shell** | Experimental | `@cloudflare/computer/backends/worker-shell` | Shell commands via [just-bash](https://github.com/vercel-labs/just-bash) in a Dynamic Worker | A Worker Loader binding; `experimental` flag; the `just-bash` package |
| **Worker JavaScript** | Stable | `@cloudflare/computer/backends/worker-javascript` | ECMAScript modules in a fresh Dynamic Worker | A Worker Loader binding |

- **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.md`](../../docs/07_injected_service.md) for
  the container image, and [`examples/container`](../../examples/container).
- **Container (legacy)** is deprecated. It serves containers the
  platform schedules from the `containers` block. Move to the container
  backend, which needs `scheduling_policy: "durable_object"` and an
  `images` map instead.
- **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.md`](../../docs/12_worker_backend.md) and
  [`examples/worker-shell`](../../examples/worker-shell).
- **Worker JavaScript** evaluates a module with structured
  input/results, durable relative imports, configured libraries,
  Workspace-backed `node:fs/promises`, and host modules such as
  `ws:git`, `ws:artifacts`, and `ws:container`. It runs after `runtime.exec()` returns; the
  run stays alive while its event stream is consumed. See
  [`docs/17_isolate_javascript.md`](../../docs/17_isolate_javascript.md)
  and [`examples/worker-javascript`](../../examples/worker-javascript).

You can register several backends on one Workspace and route each call
to a named one — see [Multiple backends](#multiple-backends).

## Tools for agents

`@cloudflare/computer/tools/ai-sdk` ships `createAITools()`, 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`, `ls`, `find`,
`grep`, `write`, `edit`, and `delete`, plus `exec` when the Workspace
has a backend and `publish` when assets are configured. Read-only mode keeps `read`, `ls`, `find`, and
`grep`.

```ts
import { createAITools } from "@cloudflare/computer/tools/ai-sdk";

const tools = createAITools({
  workspace,
  read: { maxBytes: 32 * 1024, maxLines: 800 },
  // The backends the model can use. Omit for every backend.
  exec: { shell: { description: "Try this first." }, container: {} },
});
```

Each backend describes itself to the model, and the text you give in
`exec` comes first. The model reads both when deciding where a command
should run, so write yours in plain language. Truncated text
model output keeps both line and byte continuations; pass both to the
next call to avoid transferring the same bytes again. Eligible image and
PDF bytes are captured once during the bounded tool execution and returned
as AI SDK `file` model output without re-reading the file. SVG source remains
text. `ls`, `find`, and `grep` pass pagination through to the storage layer
and return `nextOffset` when more results exist. `edit` falls back to
Unicode- and whitespace-tolerant matching while splicing replacements into
the original text, so untargeted content stays byte-for-byte unchanged. File
mutations share locks across tool sets for the same workspace, and recursive
deletion excludes mutations throughout its subtree. See
[`docs/09_tool_interface.md`](../../docs/09_tool_interface.md).

The same tools, with the same options, come for two more agent
libraries. Each entry point loads only `zod` and its own code, so
importing one never pulls in another library.

```ts
import { createPiTools } from "@cloudflare/computer/tools/pi-ai";
import { createTanStackTools } from "@cloudflare/computer/tools/tanstack-ai";

// pi: declarations for the model, and a function your loop calls per tool call.
const { tools, execute } = createPiTools({ workspace });

// TanStack AI: a list for chat({ tools }). This one asks before changing files.
const tanstackTools = createTanStackTools({ workspace, approve: "mutating" });
```

## Git

`workspace.git` is an opt-in typed git client backed by
[`isomorphic-git`](https://github.com/isomorphic-git/isomorphic-git),
operating directly on the local SQLite VFS — no backend or shell
required. Enable it by passing `createGitClient()` from
`@cloudflare/computer/git`:

```ts
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`](../../docs/13_git_interface.md).

## Sharing files

Two ways to get a file out of the workspace and into the world:

- **Assets** (`@cloudflare/computer/assets`): `createAssets(...).share`
  uploads a workspace file to R2 and returns a presigned URL. Attach it
  through `WorkspaceOptions.assets` to expose an in-shell
  `assets publish <path> [<expiry>]` command. See
  [`docs/14_assets_interface.md`](../../docs/14_assets_interface.md).
- **Artifacts** (`@cloudflare/computer/artifacts`):
  `createArtifact(binding, sessionId)` is a wrapper over
  the [Cloudflare Artifacts](https://developers.cloudflare.com/artifacts/)
  binding. Every repository name is implicitly prefixed with the session
  id, so one namespace hosts many isolated sessions. The session id is
  optional: leave it off and the client spans the namespace, listing and
  reaching every repository including those other sessions own.

```ts
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 repos

const all = createArtifact(env.ARTIFACTS);
await all.list(); // every repo in the namespace, under its stored name
```

Artifacts 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`](../../docs/15_artifacts_interface.md).

## Crossing the Worker → DO boundary

The Durable Object owns the Workspace; a Worker reaches it through a
stub. `withWorkspace` installs the plumbing, and `getWorkspace(stub)`
returns a client:

```ts
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>;
```

### Dispose your stubs

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:

- `using` the value from `getWorkspace(...)`.
- `using` the handle from `ws.runtime.exec(...)`.
- Don't worry about `ws.fs`, `ws.runtime`, or `ws.git` — they ride with
  the parent.
- Pure-value returns (`readFile` as 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`](../../docs/11_lifecycle.md#stub-disposal-contract).

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.

## Package entrypoints

| Entrypoint | Purpose |
| --- | --- |
| `@cloudflare/computer` | The `Workspace` wrapper, `workspace.runtime`, stub types, the R2 and Worker bundle mounts, and proxy classes. |
| `@cloudflare/computer/backends/container` | `ContainerBackend` and `withWorkspaceContainer`. Pulls in the computerd / capnweb sync plumbing. |
| `@cloudflare/computer/backends/container-legacy` | Deprecated. `LegacyContainerBackend` and `withLegacyWorkspaceContainer`; use `@cloudflare/computer/backends/container` instead. |
| `@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 host modules. |
| `@cloudflare/computer/modules/container` | `createContainerModule()` for `ws:container`: run container commands from isolate JavaScript. |
| `@cloudflare/computer/modules/git` | `createGitModule()` for `ws:git`: confined Git from isolate JavaScript. |
| `@cloudflare/computer/modules/artifacts` | `createArtifactsModule()` for `ws:artifacts`: Artifacts and git tokens from isolate JavaScript. |
| `@cloudflare/computer/modules/assets` | `createAssetsModule()` for `ws:assets`: publish a Workspace file from isolate JavaScript. |
| `@cloudflare/computer/modules/tools` | `createToolBindings()` for `ws:tools`: an agent's own tools, callable from isolate JavaScript. |
| `@cloudflare/computer/tools` | AI SDK tools for agents: `read`, `ls`, `find`, `grep`, `write`, `edit`, `delete`, and optional `exec` and `publish`. |
| `@cloudflare/computer/tools/core` | `defineExec()`: the `exec` tool's description, schema, and executor, with no agent library. |
| `@cloudflare/computer/tools/ai-sdk` | `createAITools()`: the AI SDK tool set for a Workspace, and `forAISDKTools()` to serve AI SDK tools as `ws:tools`. |
| `@cloudflare/computer/tools/pi-ai` | `createPiTools()`: the same tool set for pi (`@earendil-works/pi-ai`), and `forPiTools()` to serve pi tools as `ws:tools`. |
| `@cloudflare/computer/tools/tanstack-ai` | `createTanStackTools()`: the same tool set for TanStack AI (`@tanstack/ai`). |
| `@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, an optionally session-scoped wrapper over the Cloudflare Artifacts binding. |
| `@cloudflare/computer/observe/cloudflare` | Cloudflare-runtime adapter for the observability hook. |
| `@cloudflare/computer/vite` | `computerBundle()`: a Vite plugin that serves `computer:bundle/<dir>` imports for `WorkerBundle`. |
| `@cloudflare/computer/vite/client` | Types for `computer:bundle/<dir>` imports. |

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`.

## Advanced

### Multiple backends

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:

```ts
const ws = new Workspace({
  storage: ctx.storage,
  backends: [
    new WorkerShellBackend({ id: "shell", loader: env.LOADER, /* ... */ }),
    new ContainerBackend({ 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.

### Constructing without the mixin

`withWorkspace` is the shortcut. You can also construct a `Workspace`
directly and expose it through a stub yourself:

```ts
const ws = new Workspace({ storage: ctx.storage, backends: [/* ... */] });
await ws.ready();
const stub = ws.stub();  // crosses the Workers-RPC boundary
```

### Resuming an incomplete sync

A command can change backend files and then have its post-command pull
fail; the result exposes `sync: { status: "pending", ... }`. Nothing
further is required to recover it. The sync operation and its watermark
hold the progress durably, so the next `pull()` resumes from where the
failed one stopped and drains the rest of the fixed target.

A deferred exec fixes that target when the command finishes, which pins
the command's changes rather than capturing a newer target that could
have raced ahead. Attempt counts, backoff, and exhaustion are not part
of the API: a caller that wants to stop trying stops iterating.

### Observability

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

- [`examples/worker-shell`](../../examples/worker-shell) — the
  worker-shell backend behind a `write` / `read` / `exec` HTTP surface.
  No container.
- [`examples/worker-javascript`](../../examples/worker-javascript) — the
  same shape, running ECMAScript modules instead of shell commands.
- [`examples/container`](../../examples/container) — the container
  backend running `computerd`.
- [`examples/container-legacy`](../../examples/container-legacy) — the
  deprecated legacy container backend.
- [`examples/think`](../../examples/think) — a chat agent that uses the
  workspace as its working directory.
- [`examples/tutorial`](../../examples/tutorial) — a step-by-step build:
  write a markdown card on the host, render it to PDF with `pandoc` in
  the container.

## Documentation

The design specification lives under [`docs/`](../../docs/README.md).
Its topic index marks each document stable or experimental.
Start with [01. VFS](../../docs/01_vfs.md),
[04. Filesystem Interface](../../docs/04_filesystem_interface.md), and
[05. Runtime Interface](../../docs/05_runtime_interface.md). It is
forward-looking — read it for intent, not as a description of the code
today.

## License

MIT. See [`LICENSE`](../../LICENSE).
