File system·续1

src/workspace-plugin.tsts

import type { Workspace } from "@cloudflare/computer";
import { createPiTools } from "@cloudflare/computer/tools/pi-ai";
import type { Plugin } from "@opencode/plugin";

// OpenCode tools that need a local file system or process.
const LOCAL_TOOLS = ["read", "write", "edit", "patch", "glob", "grep", "shell"];

export function workspacePlugin(workspace: Workspace): Plugin.Plugin {
    const { tools, execute } = createPiTools({ workspace });
    return {
        id: "workspace",
        async setup(ctx) {
            await ctx.tool.transform((editor) => {
                for (const id of LOCAL_TOOLS) editor.remove(id);
                for (const tool of tools) {
                    editor.add({
                        name: tool.name,
                        description: tool.description,
                        input: tool.parameters,
                        // Offer each tool by name instead of through OpenCode's code mode.
                        options: { codemode: false },
                        async execute(input, context) {
                            const { content, isError } = await execute(
                                { id: context.id, name: tool.name, arguments: input },
                                { abortSignal: context.signal },
                            );
                            const text = content
                                .map((part) => (part.type === "text" ? part.text : ""))
                                .join("\n");
                            if (isError) throw new Error(text || `${tool.name} failed`);
                            return { content: text };
                        },
                    });
                }
            });
        },
    };
}

Add the plugin to the plugins array that you pass to OpenCodeWorkerd.create() in the harness factory:

OpenCodeWorkerd.create({
    storage,
    plugins: [this.ai.plugin, workspacePlugin(this.workspace)],
});
OpenCodeWorkerd.create({
    storage,
    plugins: [this.ai.plugin, workspacePlugin(this.workspace)],
});

Configure the tools

createAITools(), createPiTools(), and createTanStackTools() accept the same options:

Option Default Description
workspace Required A Workspace, or a client from getWorkspace().
readonly false Offers only read, ls, find, and grep.
read Default caps Limits for read, such as maxBytes, maxLines, and includeLineNumbers.
write Default caps Limits for write.
edit Default caps Limits for edit.
exec Every backend A map from backend ID to { description }. Pass {} to leave out the exec tool.

To let the model read and search files but not change them, pass readonly: true:

createAITools({ workspace: this.workspace, readonly: true });
createAITools({ workspace: this.workspace, readonly: true });

Run JavaScript in the workspace

Add a WorkerJavaScriptBackend to let the agent, or the model, run JavaScript against workspace files. Each run is an ECMAScript module that executes in a new Dynamic Worker. It is faster and cheaper to run than a container. Network access is configurable but off by default, and the module reaches the workspace through node:fs/promises.

import { Workspace } from "@cloudflare/computer";
import { WorkerJavaScriptBackend } from "@cloudflare/computer/backends/worker-javascript";
import { DurableObject } from "cloudflare:workers";

export class NotesAgent extends DurableObject {
    workspace = new Workspace({
        storage: this.ctx.storage,
        backends: [new WorkerJavaScriptBackend({ loader: this.env.LOADER })],
    });
}
import { Workspace } from "@cloudflare/computer";
import { WorkerJavaScriptBackend } from "@cloudflare/computer/backends/worker-javascript";
import { DurableObject } from "cloudflare:workers";

export class NotesAgent extends DurableObject<Env> {
    workspace = new Workspace({
        storage: this.ctx.storage,
        backends: [new WorkerJavaScriptBackend({ loader: this.env.LOADER })],
    });
}

In a Think agent, set the same Workspace as override workspace. Once the workspace has a backend, every tool adapter includes exec.

The backend needs a Worker Loader binding:

{
    // Set this to today's date
    "compatibility_date": "2026-10-27",
    "compatibility_flags": ["nodejs_compat"],
    "worker_loaders": [
        {
            "binding": "LOADER"
        }
    ]
}
# Set this to today's date
compatibility_date = "2026-10-27"
compatibility_flags = [ "nodejs_compat" ]

[[worker_loaders]]
binding = "LOADER"

The exec tool describes the backend to the model, including the modules that its code can import, so the model can write a module like the following without further instructions:

import fs from "node:fs/promises";

export default async function (input) {
    const names = await fs.readdir("/workspace/notes");
    const counts = {};
    for (const name of names) {
        const text = await fs.readFile(`/workspace/notes/${name}`, "utf8");
        counts[name] = text.split(/\s+/).filter(Boolean).length;
    }
    return counts;
}

The module must have a default export. A default-exported function is called with the input of the run, and its return value becomes the result. Put file and module calls inside that function. The Workers runtime does not allow I/O while a module loads, so accessing the file system at the top level of the module fails the run.

Run code from the agent

Your own code can run a module through workspace.runtime.exec(). Pass structured data as input, and read the return value from value:

using run = await this.workspace.runtime.exec(
    `
    import fs from "node:fs/promises";

export default async function ({ path }) {
        const text = await fs.readFile(path, "utf8");
        return { lines: text.split("\\n").length };
    }
    `,
    { input: { path: "/workspace/notes/todo.md" }, encoding: "utf8" },
);

const { exitCode, value, stdout, stderr } = await run.result();
using run = await this.workspace.runtime.exec(
    `
    import fs from "node:fs/promises";

export default async function ({ path }) {
        const text = await fs.readFile(path, "utf8");
        return { lines: text.split("\\n").length };
    }
    `,
    { input: { path: "/workspace/notes/todo.md" }, encoding: "utf8" },
);

const { exitCode, value, stdout, stderr } = await run.result();

console.log() writes to stdout, and console.error() writes to stderr. The input and the return value must be JSON-compatible plain data. A module can also import other files in the workspace with relative imports, such as import { summarize } from "./lib/summarize.js", which resolve from the cwd option of the run.

Configure the backend

WorkerJavaScriptBackend accepts the following common options:

Option Default Description
loader Required The Worker Loader binding.
id "worker-javascript" The backend ID that exec and runtime.exec() use to select this backend.
root /workspace The directory that code can access. Paths outside it are rejected.
access "read-write" Set to "read" to let code read files but not change them.
defaultTimeoutMs 60000 How long a run can take when the caller does not set a timeout.
maxTimeoutMs 180000 The longest timeout that a caller can request.
globalOutbound null A Fetcher that receives every outbound request from the code. null blocks network access.
modules — Extra modules that code can import, such as bundled libraries or functions that run in the agent.
onInterrupt "fail" What happens to runs that a restart interrupts. Refer to Resume runs after a restart.
onWait — Called when a host module call starts waiting for an answer.

Give code access to your own functions

Custom modules can be registered under the ws: namespace. Use these modules to give the model access to your application logic or file system tools, or to perform external actions.

// In the `backends` list of the workspace.
new WorkerJavaScriptBackend({
    loader: this.env.LOADER,
    modules: {
        "ws:orders": {
            // Arguments arrive as an array of JSON-compatible values.
            lookup: ([orderId]) => this.lookUpOrder(String(orderId)),
        },
    },
});
// In the `backends` list of the workspace.
new WorkerJavaScriptBackend({
    loader: this.env.LOADER,
    modules: {
        "ws:orders": {
            // Arguments arrive as an array of JSON-compatible values.
            lookup: ([orderId]) => this.lookUpOrder(String(orderId)),
        },
    },
});

Code imports the module like any other:

import { lookup } from "ws:orders";

export default async function ({ ids }) {
    return Promise.all(ids.map((id) => lookup(id)));
}

Always validate input to your module functions, because it comes directly from the model. Computer also includes prebuilt host modules for Git, Artifacts, and the agent's own tools. For more information, refer to Host modules in the API reference.

Resume runs after a restart

Experimental

Resuming runs and waiting for answers are experimental. Their behavior and stored data may change in any release, and a run interrupted across such an upgrade fails instead of resuming.

A deploy or a crash restarts the Durable Object and ends every JavaScript run in flight. The onInterrupt option decides what happens to those runs. Set a default on the backend, and override it for a single run in exec():

new WorkerJavaScriptBackend({ loader: this.env.LOADER, onInterrupt: "replay" });

await this.workspace.runtime.exec(source, { onInterrupt: "fail" });
new WorkerJavaScriptBackend({ loader: this.env.LOADER, onInterrupt: "replay" });

await this.workspace.runtime.exec(source, { onInterrupt: "fail" });
Value After a restart
"fail" (default) The run fails.
"restart" The run starts again from the beginning. Calls it already made run again, and its output repeats.
"replay" The run starts again from the beginning, but calls it already made are answered from a journal, and its output is not repeated. Then it continues live.

The code being run does not need to change for either policy.

A replayable run writes every file system call, host module call, and fetch response to a journal in SQLite before the code sees the result. On replay, the code gets the same responses in the same order. Clocks, timers, Math.random(), and crypto.randomUUID() also return the same values, so the run takes the same path. Everything saved for a run is deleted when the run finishes.

Interrupted runs resume the next time something calls workspace.runtime, such as exec() or getExec(). Nothing else wakes a Durable Object after a deploy. To resume runs without waiting for traffic, call workspace.runtime.recover() from the alarm of the Durable Object.

Calls that must not run twice

A call that was still running at the restart never returned, so the replay runs it again. Each host module call gets a callId that stays the same across that rerun, which you can pass on as an idempotency key. When a function cannot use one, wrap it in unsafeToRepeat(). An interruption during that call then fails the run instead.

import { unsafeToRepeat } from "@cloudflare/computer";

new WorkerJavaScriptBackend({
    loader: this.env.LOADER,
    onInterrupt: "replay",
    modules: {
        "ws:billing": {
            refund: ([order], { callId }) =>
                this.refund(String(order), { idempotencyKey: callId }),
            charge: unsafeToRepeat(([order, cents]) =>
                this.charge(String(order), Number(cents)),
            ),
        },
    },
});
import { unsafeToRepeat } from "@cloudflare/computer";

new WorkerJavaScriptBackend({
    loader: this.env.LOADER,
    onInterrupt: "replay",
    modules: {
        "ws:billing": {
            refund: ([order], { callId }) =>
                this.refund(String(order), { idempotencyKey: callId }),
            charge: unsafeToRepeat(([order, cents]) =>
                this.charge(String(order), Number(cents)),
            ),
        },
    },
});

Wait for an answer

A host module function can pause a replayable run until someone answers, for example to approve an action. The code in the isolate calls the function as usual, and gets its result once the answer arrives.

new WorkerJavaScriptBackend({
    loader: this.env.LOADER,
    onInterrupt: "replay",
    modules: {
        "ws:github": {
            postComment: async ([issue, body], ctx) => {
                const { approved } = await ctx.interrupt({
                    issue,
                    body,
                });
                if (!approved) throw new Error("The comment was rejected.");
                return this.postComment(issue, body, { idempotencyKey: ctx.callId });
            },
        },
    },
});
new WorkerJavaScriptBackend({
    loader: this.env.LOADER,
    onInterrupt: "replay",
    modules: {
        "ws:github": {
            postComment: async ([issue, body], ctx) => {
                const { approved } = await ctx.interrupt<{ approved: boolean }>({
                    issue,
                    body,
                });
                if (!approved) throw new Error("The comment was rejected.");
                return this.postComment(issue, body, { idempotencyKey: ctx.callId });
            },
        },
    },
});

ctx.interrupt(payload) releases the isolate of the run once its other calls in flight finish. The run stays running, and time spent waiting does not count toward its timeout. A waiting run survives deploys like any interrupted run.