How workspace.runtime.exec Handles Structured Input for Callable Backends in Cloudflare Computer

workspace.runtime.exec accepts a structured input object that is JSON-encoded and transmitted to callable backends via Cap'n Proto RPC, enabling type-safe, data-driven function execution within Cloudflare's Computer framework.

The Cloudflare Computer repository provides a workspace-based execution environment where backends can operate in different modes. When a backend is marked as callable, it receives structured input rather than raw command strings. This article examines the complete data flow—from the exec method through backend resolution to RPC transport—using the actual source code implementation.

What Are Callable Backends?

Callable backends are a specialized backend type in Computer that expose a function-like interface. Unlike traditional command-based backends that parse shell-style input, callable backends receive deserialized JavaScript objects directly.

A backend becomes callable by setting callable: true in its registration:

const callableBackend = createBackend({
  id: "my-fn",
  type: "callable",
  callable: true,  // Marks this backend as callable
  async connect({ db, fs }) {
    return {
      exec: async ({ source, input }) => {
        // `input` is the structured payload from the caller
        const result = await processData(input);
        return { id: generateId(), events: [], sync: "none" };
      },
      // getExec, killExec, disposeExec implementations...
    };
  },
});

The callable flag causes the backend ID to be included in callableBackendIds, which WorkspaceRuntime uses to route execution requests appropriately.

The Execution Flow: From exec() to Backend

Step 1: Workspace Creates the Runtime

In [packages/computer/src/workspace.ts](https://github.com/cloudflare/computer/blob/main/packages/computer/src/workspace.ts), the Workspace class constructs a WorkspaceRuntime instance during initialization:

// Workspace constructor – lines 31-32
this.runtime = new WorkspaceRuntime({
  callableBackendIds: this.getCallableBackendIds(),
  backendHandle: (id) => this.resolveBackendHandle(id),
});

The callableBackendIds set determines which backends receive structured input handling versus traditional command execution.

Step 2: Building the Execution Request

When you call workspace.runtime.exec(), the method constructs a ModuleExecutionInput object. From [packages/computer/src/runtime/runtime.ts](https://github.com/cloudflare/computer/blob/main/packages/computer/src/runtime/runtime.ts) (lines 64-71):

// WorkspaceRuntime.exec – input handling
async exec(
  source: string,
  options: ExecOptions = {}
): Promise<ExecutionHandle> {
  const input: ModuleExecutionInput = {
    source,
    input: options.input,        // Structured input object (JSON-serializable)
    // ... additional metadata
  };
  
  // Backend resolution and RPC transmission
  const backendId = options.backend ?? this.defaultBackendId;
  const handle = await this.backendHandle(backendId);
  // ...
}

The options.input field carries the structured payload. This is the critical path where JavaScript objects become backend-bound data.

Step 3: Backend Resolution and Callable Routing

The runtime checks whether the target backend is callable (lines 82-89 in runtime.ts):

// Backend resolution with callable check
const isCallable = this.callableBackendIds.has(backendId);
if (isCallable) {
  // Route to module-backend callable interface
  return this.executeCallable(backendId, input);
}
// Otherwise, fall back to standard command execution

Callable backends bypass shell-style parsing entirely. The source parameter becomes largely ceremonial—only input matters for execution semantics.

Step 4: RPC Transport via Cap'n-Web

The structured input traverses the wire through [packages/computer/src/runtime/wire.ts](https://github.com/cloudflare/computer/blob/main/packages/computer/src/runtime/wire.ts) (lines 30-45):

// RPC envelope creation
function createExecutionEnvelope(input: ModuleExecutionInput): ArrayBuffer {
  const serialized = JSON.stringify(input.input);  // JSON-encoding
  // Build Cap'n Proto message with serialized payload
  return capnp.serialize({
    source: input.source,
    payload: serialized,           // Structured data as JSON string
    executionId: input.id,
  });
}

Key constraint: The input object must be JSON-serializable. Functions, circular references, and undefined values are stripped or cause serialization failures.

Step 5: Backend Execution and Result Return

The callable backend receives the deserialized input and returns a ModuleExecutionEnvelope. The result path (lines 126-133 in runtime.ts):

// Result handling
async result(): Promise<any> {
  const envelope = await this.awaitResponse();
  // Decode JSON payload from backend
  return JSON.parse(envelope.result);
}

The round-trip preserves structured data: caller's object → JSON → backend → result object → JSON → caller.

Practical Code Examples

Basic Structured Input Execution

// Execute with explicit structured input
const handle = await ws.runtime.exec("compute", {
  backend: "analytics-processor",
  input: {
    dataset: "user-events",
    timeRange: { start: "2024-01-01", end: "2024-01-31" },
    aggregations: ["count", "sum"],
    filters: { region: "US" }
  },
});

const result = await handle.result();
console.log(result.totals);  // Structured result from backend

Tagged Template Syntax

Computer supports convenient tagged templates that automatically structure input:

// Template literal form – first argument becomes command, 
// interpolated values become input
await ws.runtime.exec`analytics-processor ${{
  dataset: "user-events",
  timeRange: { start: "2024-01-01", end: "2024-01-31" },
}}`;

The template engine extracts the command identifier and packages the interpolated object as input.

Streaming Results from Callable Backends

const handle = await ws.runtime.exec("stream-process", {
  backend: "event-stream",
  input: { source: "kafka-orders", batchSize: 100 },
});

// Consume incremental results
for await (const chunk of handle.events()) {
  console.log("Batch processed:", chunk.count);
}

// Final aggregation
const final = await handle.result();

Error Handling with Structured Context

try {
  await ws.runtime.exec("validate", {
    backend: "schema-validator",
    input: { document: malformedData },
  });
} catch (e) {
  // Error includes structured validation failures if backend provides them
  console.error("Validation failed:", e.details?.failures);
}

Input Validation and Constraints

JSON Serialization Requirements

Valid Input Invalid Input
Plain objects {a: 1} Functions () => {}
Arrays [1, 2, 3] Circular references
Primitives string, number, boolean, null undefined (omitted)
Nested structures Symbol, BigInt

The runtime performs no schema validation—backends must defend against malformed input.

Execution ID Constraints

From runtime.ts validation logic:

// Execution ID validation
if (!input.id || input.id.length === 0) {
  throw new Error("Execution ID required");
}
if (input.id.length > 256) {
  throw new Error("Execution ID exceeds 256 byte limit");
}

Connection Resilience

If the underlying RPC stream fails, WorkspaceRuntime invalidates the cached backend handle (lines 145-152 in runtime.ts):

// Transport error recovery
} catch (e) {
  if (isTransportError(e)) {
    this.backendHandleCache.delete(backendId);  // Force reconnect
  }
  throw e;
}

The next exec call triggers fresh connection establishment without caller intervention.

Summary

  • workspace.runtime.exec accepts structured input through the options.input field, enabling data-driven backend execution
  • Callable backends receive JSON-deserialized objects directly, bypassing shell-style command parsing
  • Transport layer uses Cap'n-Web RPC with JSON-encoded payloads; all data must survive JSON.stringify
  • Backend resolution checks callableBackendIds to determine execution path
  • Result handling automatically decodes JSON responses into JavaScript values
  • Connection failures trigger transparent reconnection via cache invalidation

Frequently Asked Questions

What happens if I pass non-JSON data as input?

The runtime attempts JSON.stringify() during RPC envelope creation. Non-serializable values like functions or circular references typically cause TypeError exceptions. The specific behavior depends on your JSON serializer configuration—some values may be silently omitted rather than throwing.

Can I use callable backends without specifying a command string?

Yes. For callable backends, the source parameter in exec(source, options) is largely ignored by the backend implementation. You can pass any string (commonly the backend ID for clarity) or use the tagged template form where the template literal's first part becomes the nominal command.

How does the runtime distinguish callable from non-callable backends?

During Workspace construction, backends with callable: true have their IDs collected into a Set passed to WorkspaceRuntime as callableBackendIds. The runtime performs an O(1) lookup via this.callableBackendIds.has(backendId) to select the execution path—callable backends skip shell parsing and receive direct object input.

Is there a size limit on structured input?

There's no explicit byte limit enforced by runtime.ts, but practical constraints apply: (1) JSON serialization overhead, (2) Durable Object message size limits (typically 1MB for WebSocket messages), and (3) execution ID length limit of 256 bytes. Large inputs should use streaming patterns or external storage references.

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 →