CapnWeb RPC Interface for Durable Object to Computerd Communication

The CapnWeb RPC interface connects Cloudflare Durable Objects to the in-container computerd process via a long-lived WebSocket carrying JSON-framed WorkspaceRPC calls, split into SyncRPC for filesystem synchronization and ShellRPC for process execution.

The cloudflare/computer repository implements a sandboxed compute environment where Durable Objects orchestrate containers running the computerd daemon. Communication between these layers relies on the CapnWeb RPC interface, a lightweight transport that exposes filesystem changes and command execution primitives over a stable WebSocket channel. This architecture enables bidirectional synchronization of filesystem state and interactive shell access while maintaining strict error boundaries between the DO and container contexts.

Transport Layer and Connection Model

The CapnWeb RPC layer operates over two primary transport mechanisms defined in the computerd architecture. Understanding these endpoints is critical for implementing custom clients or debugging connection failures.

WebSocket and HTTP Fallback Endpoints

The primary transport uses a single long-lived WebSocket connected to the /ws endpoint. When WebSocket connections are unavailable, the system falls back to an HTTP batch interface at /api. The wire format strictly uses JSON-text frames; binary WebSocket frames are not supported in the current implementation.

Connection resilience is built into the protocol: when the socket closes or errors, the Durable Object side aborts the session synchronously. Subsequent RPC calls automatically trigger re-establishment of a fresh connection, ensuring that transient network disruptions do not require manual reconnection logic at the application level.

WorkspaceRPC Root Interface

The RPC surface exposed over this channel centers on the WorkspaceRPC interface defined in packages/rpc/src/interface.ts. This top-level stub acts as the exclusive entry point for all cross-container communication and splits cleanly into two independent halves.

SyncRPC for Filesystem Operations

SyncRPC handles bidirectional synchronization of filesystem changes and object blobs. The DO pushes batches of ChangeEntry records (containing only chunk hashes) via the push method. The container probes its local cache using hasObjects, then the DO streams missing blobs via pushObjects. The reverse flow operates through fetchChanges followed by fetchObjects, allowing each side to pull missing updates. Diagnostic visibility comes through watermarks, which expose current revision numbers and fetch cursors.

ShellRPC for Process Supervision

ShellRPC provides process-supervision primitives including exec, getExec, killExec, and disposeExec. This separation keeps sync logic testable while presenting a single stable stub to callers, preventing filesystem operations from blocking on long-running shell commands.

Synchronization Protocol Details

The filesystem synchronization protocol operates on a content-addressed model where only hashes traverse the RPC boundary initially.

Pushing Changes from DO to Container

When the Durable Object has local filesystem modifications, it calls sync.push() with a ReadableStream<ChangeEntry> and the current revision number. The container examines the change entries, identifies missing object hashes, and requests them via subsequent hasObjects and pushObjects calls. The response contains the new remote revision (rev) and the applied push cursor, confirming atomic application of the batch.

Fetching Incremental Updates

To pull updates from the container, the DO invokes sync.fetchChanges() passing a ChangeCursor representing the last seen state. The response provides a stream of ChangeEntry objects along with currentCursor and appliedPushCursor metadata, enabling the DO to resume synchronization without missing intermediate updates.

Shell Execution and Stream Management

Process execution follows a streaming model that maintains back-pressure between the container and the orchestrating Durable Object.

Spawning and Monitoring Processes

The shell.exec() method accepts a command specification including source and cwd, returning a unique execution id and a ReadableStream<ExecEvent>. This stream yields stdout, stderr, and exit frames until the process terminates. Consumer back-pressure propagates to the spawned process, preventing buffer overflow in the container.

Lifecycle Management

After disconnections, the DO can re-attach to running executions using getExec() with the execution id. Termination signals travel via killExec(), while disposeExec() performs cleanup of completed process logs. These primitives allow the DO to survive intermittent connectivity without losing track of background jobs.

Error Handling with WireError

Errors traverse the CapnWeb boundary as structured WireError objects carrying specific code values. Common error codes include ENOENT for missing files, EUNKNOWN_HASH for unrecognized content addresses, and EEXEC_BUSY when attempting conflicting operations on active executions.

The client adapter in the Durable Object re-throws these as WorkspaceError instances, preserving the error code for reliable handling. This structure allows the DO to distinguish between filesystem errors, RPC transport failures, and container state conflicts without parsing error message strings.

Versioning and Compatibility Constraints

The RPC contract has no runtime version negotiation mechanism. Any change to request or response shapes constitutes a hard wire break requiring coordinated rollout of both the Durable Object code and the computerd container image. This design choice eliminates compatibility code paths but mandates strict deployment synchronization between edge and container infrastructure.

Implementation Examples

Creating a CapnWeb RPC Session

The following pattern from packages/computer/src/backends/container/cloudflare-container.ts demonstrates establishing the root WorkspaceRPC stub:

import { RpcTarget } from "capnweb";
import { WorkspaceRPC } from "@cloudflare/computer-rpc";
import { newWebSocketRpcSession } from "capnweb";

/**
 * Connect to the computerd WebSocket endpoint and obtain the WorkspaceRPC stub.
 */
async function createWorkspaceClient(url: string): Promise<WorkspaceRPC> {
  // `url` points to the DO's `/ws` endpoint, e.g. `https://example.com/ws`
  const wsTransport = await newWebSocketRpcSession(url);
  // The root stub implements `WorkspaceRPC`
  const rootStub: RpcTarget = wsTransport.stub;
  // The `RpcTarget` is a Proxy that exposes the typed interface
  return rootStub as WorkspaceRPC;
}

Pushing Filesystem Changes

To synchronize local state to the container:

async function pushChanges(workspace: WorkspaceRPC) {
  const changes: ReadableStream<ChangeEntry> = /* … generate ChangeEntry stream … */;

  const result = await workspace.sync.push({
    senderRev: 5,               // DO's current revision
    changes,
  });

  console.log("Remote rev:", result.rev);
  console.log("Applied push cursor:", result.appliedPushCursor);
}

Fetching Remote Updates

To pull incremental changes from the container:

async function fetchUpdates(workspace: WorkspaceRPC, after: ChangeCursor) {
  const { currentCursor, appliedPushCursor, stream } =
    await workspace.sync.fetchChanges({ after });

  // Consume the ChangeEntry stream
  const reader = stream.getReader();
  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    // Process each ChangeEntry (e.g., apply to local VFS)
  }

  console.log("Current cursor:", currentCursor);
  console.log("Applied push cursor:", appliedPushCursor);
}

Executing Shell Commands

To run a command and stream its output:

async function runCommand(workspace: WorkspaceRPC) {
  const { id, events } = await workspace.shell.exec({
    source: "ls -l /workspace",
    cwd: "/workspace",
  });

  const reader = events.getReader();
  while (true) {
    const { value, done } = await reader.read();
    if (done) break;

    if (value.name === "stdout") {
      console.log("[stdout]", new TextDecoder().decode(value.value));
    } else if (value.name === "stderr") {
      console.error("[stderr]", new TextDecoder().decode(value.value));
    } else if (value.name === "exit") {
      console.log(`Process exited with code ${value.code}`);
    }
  }

  // Clean up the exec log when finished
  await workspace.shell.disposeExec({ id });
}

Summary

  • The CapnWeb RPC interface uses a WebSocket transport at /ws with an HTTP fallback at /api, transmitting only JSON-text frames.
  • The root WorkspaceRPC interface defined in packages/rpc/src/interface.ts splits into SyncRPC for filesystem operations and ShellRPC for process management.
  • Synchronization follows a hash-then-content pattern: ChangeEntry records flow first, followed by blob transmission via pushObjects and fetchObjects.
  • Process execution returns ReadableStream<ExecEvent> allowing real-time stdout/stderr consumption with automatic re-attachment support via getExec.
  • Errors propagate as structured WireError objects with codes like ENOENT and EEXEC_BUSY, wrapped locally as WorkspaceError.
  • No runtime versioning exists; protocol changes require coordinated deployment of Durable Object code and computerd images.

Frequently Asked Questions

What transport protocol does the CapnWeb RPC interface use?

The interface primarily uses a single long-lived WebSocket connection to the /ws endpoint, with an HTTP batch fallback at /api for environments unable to maintain WebSocket connections. The wire format strictly uses JSON-text frames, and binary WebSocket frames are not supported.

How does error handling work across the Durable Object to computerd boundary?

Errors serialize as structured WireError objects containing a machine-readable code field (such as ENOENT, EUNKNOWN_HASH, or EEXEC_BUSY). The client adapter re-throws these as WorkspaceError exceptions, preserving the original error code for programmatic handling without requiring string parsing.

What happens when the WebSocket connection drops?

The Durable Object side synchronously aborts the current session when the socket closes or errors. The next RPC call automatically triggers re-establishment of a fresh connection. For shell executions, the DO can use getExec() to re-attach to running processes after reconnection.

Is there runtime version negotiation in the CapnWeb RPC protocol?

No. The RPC contract intentionally lacks runtime version negotiation. Any modification to request or response shapes constitutes a hard wire break that requires coordinated rollout of both the Durable Object implementation and the computerd container image to prevent serialization mismatches.

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 →