Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/pi-tanstack-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@cloudflare/computer": minor
---

Add the workspace tools for two more agent libraries, so the same
`read`, `ls`, `find`, `grep`, `write`, `edit`, `delete`, and `exec`
tools now work whichever of three libraries an agent is built on.
`@cloudflare/computer/tools/pi` serves pi, which keeps the tool list
apart from the code that runs the tools, so it returns both the
declarations and a dispatcher. `@cloudflare/computer/tools/tanstack`
serves TanStack AI, which runs the tools itself and takes them as a
list, so a list is what it returns; `format: "object"` keys them by
name when a single tool has to be reached. Both are built from one
shared description of each tool, so names, descriptions and limits
match the existing AI SDK entrypoint, which is unchanged. Each library
is an optional peer dependency, so installing one does not pull in the
others.
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ public surface. Each is a Worker workspace with its own README.
- [`examples/rlm`](examples/rlm) — shows how generated JavaScript can read long
context from a Computer Workspace, call bounded model workers, and reduce their
structured results with code.
- [`examples/pi`](examples/pi) — a one-shot [pi](https://github.com/earendil-works/pi)
agent: a minimal loop that asks a model, runs the workspace tools it asks
for, and repeats until it is done.
- [`examples/tanstack-ai`](examples/tanstack-ai) — the same one-shot agent on
[TanStack AI](https://tanstack.com/ai), where `chat()` owns the loop.
- [`examples/think`](examples/think) — a [`@cloudflare/think`](https://www.npmjs.com/package/@cloudflare/think)
chat agent that uses the workspace as its working directory, reachable
from a terminal.
Expand Down
127 changes: 125 additions & 2 deletions docs/09_tool_interface.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,56 @@
# 09. Tool interface (agents)

`@cloudflare/computer/tools` ships ready-made [AI SDK](https://github.com/vercel/ai) tools for agents that use a `Workspace`.
`@cloudflare/computer/tools` ships ready-made tools for agents that use a `Workspace`. Three agent SDKs are supported from one implementation:

| SDK | Entrypoint | Factory |
| --- | --- | --- |
| [AI SDK](https://github.com/vercel/ai) (`ai`) | `@cloudflare/computer/tools` | `createAITools` |
| [pi](https://github.com/earendil-works/pi) (`@earendil-works/pi-ai`) | `@cloudflare/computer/tools/pi` | `createPiTools` |
| [TanStack AI](https://tanstack.com/ai) (`@tanstack/ai`) | `@cloudflare/computer/tools/tanstack` | `createTanStackTools` |

The tools wrap three Workspace surfaces:

- `workspace.fs` for file reads, writes, edits, searches, listings, and deletion;
- `workspace.runtime.exec` for command execution when the caller opts in;
- `workspace.assets` for publishing generated files when an assets publisher is configured.

Every factory takes the same options and produces the same tools with the same names, descriptions, schemas, and caps. Only the returned shape differs, because each SDK wants a different one. Each SDK's package is an optional peer dependency: importing one adapter does not require the other two to be installed.

## One implementation, three shapes

A tool is described once as a `ToolSpec` — a name, a description, a Zod input schema, an executor, and an optional model-output hook — and `createToolSpecs()` assembles the set for a Workspace. The three factories are thin adapters over that set, so a change to a tool's behavior, schema, or description reaches all three SDKs at once.

```
createToolSpecs() ← tool set, gating, schemas, caps
/ | \
createAITools createPiTools createTanStackTools
(ai) (pi-ai, TypeBox) (@tanstack/ai)
```

Where the SDKs genuinely differ, the adapter absorbs it:

| Concern | AI SDK | pi | TanStack AI |
| --- | --- | --- | --- |
| Schema | Zod, passed through | converted to JSON Schema for TypeBox | Zod, passed through (Standard Schema) |
| Execution | `execute` on the tool | caller's loop, via the returned `execute` dispatcher | `execute` on the tool |
| Streaming `exec` | progressive tool results | terminal snapshot | terminal snapshot, optional custom events |
| Images and PDFs | typed `file` output part | base64 `image` block (PDFs degrade to text) | base64 payload plus media type |
| Argument enforcement | schema validation | provider-side constrained sampling | schema validation |
| Result shape | `toModelOutput` | `toolResult` content blocks | `outputSchema` |
| Approval and discovery | — | — | `needsApproval`, `lazy` |

Sharing the implementation does not mean levelling every SDK down to the smallest common feature set. A spec carries SDK-agnostic *traits* — whether a tool mutates state, whether its arguments are fussy enough to be worth constraining, whether it streams — and each adapter lowers those onto whatever its SDK offers, ignoring the ones it cannot use. So `edit` asks to be constrained once, and pi turns that into provider-side strict sampling while the other two simply validate.

`createToolSpecs` and the `ToolSpec` types are exported, so a fourth SDK is an adapter rather than a rewrite.

## What ships

| Export | Purpose |
| --- | --- |
| `createAITools` | Create the default AI SDK `ToolSet` for a Workspace. |
| `createPiTools` | Create pi tool declarations plus their executor. |
| `createTanStackTools` | Create the TanStack AI tool list for a Workspace. |
| `createToolSpecs` | Build the SDK-neutral spec set the adapters share. |
| `createReadTool` | Stream text by line and pass images or PDFs to capable models. |
| `createWriteTool` | Write a whole file with a UTF-8 byte cap. |
| `createEditTool` | Apply atomic targeted replacements and return a unified diff. |
Expand All @@ -24,7 +62,7 @@ The tools wrap three Workspace surfaces:
| `createPublishTool` | Publish a workspace file through `workspace.assets`. |
| `WorkspaceFileStore` | Adapt `workspace.fs` to the store used by file tools. |

`createAITools()` always names its tools `read`, `ls`, `find`, `grep`, `write`, `edit`, and `delete`. `exec` appears when the caller supplies `shell` options. `publish` appears when assets are configured. In read-only mode the set is `read`, `ls`, `find`, and `grep`.
Every factory always names its tools `read`, `ls`, `find`, `grep`, `write`, `edit`, and `delete`. `exec` appears when the caller supplies `shell` options. `publish` appears when assets are configured. In read-only mode the set is `read`, `ls`, `find`, and `grep`.

## Wiring up

Expand Down Expand Up @@ -55,6 +93,91 @@ export class Agent {

Pass the returned AI SDK `ToolSet` to `generateText`, `streamText`, or an agent framework hook such as `getTools()`.

### pi

pi splits a tool into data and execution: `Context.tools` carries declarations with TypeBox `parameters`, and the caller's own agent loop runs the calls. `createPiTools` returns both halves so they cannot drift apart.

```ts
import { createPiTools } from "@cloudflare/computer/tools/pi";
import { builtinModels } from "@earendil-works/pi-ai/providers/all";

const { tools, execute } = createPiTools({ workspace });
const models = builtinModels();
const model = models.getModel("anthropic", "claude-sonnet-4-5")!;

const context = {
systemPrompt: "You are a coding agent working in /workspace.",
messages: [{ role: "user", content: "Summarize the README.", timestamp: Date.now() }],
tools,
};

// One turn of the caller's loop.
const message = await models.complete(model, context);
context.messages.push(message);

for (const block of message.content) {
if (block.type !== "toolCall") continue;
const { content, isError } = await execute(block);
context.messages.push({
role: "toolResult",
toolCallId: block.id,
toolName: block.name,
content,
isError,
timestamp: Date.now(),
});
}
```

`execute` validates the call's arguments against the tool's schema and returns pi `toolResult` content, reporting a bad call or a failed tool as `isError: true` so the model can retry instead of the loop throwing. The Zod schemas are converted to plain JSON Schema for TypeBox, so a field with a default stays optional for the model and pi applies the default during validation.

Tools whose arguments are structurally fussy — `read`, `write`, and `edit`, which carry byte offsets and long verbatim strings — are declared with pi's `constrainedSampling`, so a provider that supports it enforces the schema during sampling and a malformed `edit` never reaches the tool. Strict enforcement requires a closed schema in which every property is listed, so those declarations set `additionalProperties: false`, mark each optional field nullable, and the dispatcher drops the resulting top-level nulls before validation.

The default is `strict: "prefer"`, which falls back to ordinary tool calling on a provider that cannot enforce a schema. Pass `constrainedSampling: "require"` to fail the request instead, when the caller pins a model known to support it, or `false` to opt out and get the open schemas:

```ts
const { tools } = createPiTools({ workspace, constrainedSampling: "require" });
```

### TanStack AI

A TanStack tool is a plain object whose `inputSchema` is a Standard Schema, which Zod implements, so the schemas are passed through with no conversion. The tools come back as a list, which is what every TanStack entry point takes: `chat({ tools })`, `mergeAgentTools`, and `createToolRegistry` all want an array. Pass `format: "object"` to get them keyed by name instead, for reaching one tool directly, such as to adjust a single tool before the call.

```ts
import { chat, toServerSentEventsResponse } from "@tanstack/ai";
import { anthropicText } from "@tanstack/ai-anthropic";
import { createTanStackTools } from "@cloudflare/computer/tools/tanstack";

export async function POST(request: Request) {
const { messages } = await request.json();
const abortController = new AbortController();

const tools = createTanStackTools({
workspace,
shell: { defaultBackend: "shell", backends: { shell: { description: "Worker shell." } } },
approve: "mutating",
signal: abortController.signal,
});

return toServerSentEventsResponse(
chat({
adapter: anthropicText("claude-sonnet-4-5"),
messages,
tools,
abortController,
}),
);
}
```

`approve` marks tools that pause for confirmation through TanStack's `needsApproval`. Pass a name list, or `"mutating"` to gate every tool that changes workspace state so the list does not have to be restated as the tool set grows. `lazy` takes the same shape and marks tools to withhold from the prompt until TanStack's lazy discovery asks for them, which keeps a workspace tool set out of the system prompt for an agent whose file work is occasional.

The tools that have one fixed result shape — `write`, `edit`, `delete`, and `publish` — also carry an `outputSchema`, which TanStack validates client-side and threads into its typed hooks. Paged tools like `ls` omit it. The schema covers the error branch as well as success, because TanStack validates every return against it: a success-only schema would replace a real failure reason with a validation complaint.

Because the tool execution context carries no abort signal, pass `signal` to cancel a running `exec` when the request aborts. A TanStack tool settles on one value, so `exec` returns the run's terminal snapshot; set `streamEventName` to also forward each pre-terminal snapshot through `emitCustomEvent` for a live view of a command's output.

### Shared options

Pass `shell` only when the Workspace has matching backend ids:

```ts
Expand Down
46 changes: 46 additions & 0 deletions examples/pi/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# pi agent

A one-shot agent built on [pi](https://github.com/earendil-works/pi). Send it a
task, it works in a durable Workspace, and it replies when it is done.

The whole agent loop is the `run` method in [`src/index.ts`](src/index.ts): ask
the model, run whatever tools it asked for, repeat until it stops asking. pi
keeps the list of tools separate from the code that runs them, so
`createPiTools` hands back both — `tools` to show the model, and `execute` to
run one of its requests.

The workspace tools come from
[`@cloudflare/computer/tools/pi`](../../docs/09_tool_interface.md): `read`,
`ls`, `find`, `grep`, `write`, `edit`, `delete`, and `exec`.

[`src/workers-ai.ts`](src/workers-ai.ts) teaches pi to reach Workers AI through
the `AI` binding rather than the REST endpoint, so the example needs no API
key. It is lifted from the pi harness example in
[cloudflare/agents](https://github.com/cloudflare/agents).

## Run it

```sh
npm install
npm run dev --workspace @example/computer-pi
```

Then give it something to do:

```sh
curl -X POST http://localhost:8787 \
-H 'content-type: application/json' \
-d '{"task":"Write a haiku about durable objects to /workspace/haiku.txt, then read it back."}'
```

The agent writes the file with the `write` tool and reads it back with `read`,
then says what it did. Ask it to `grep` or run a shell command and it will
reach for those tools instead.

This uses the remote Workers AI binding and counts against your account's
Workers AI usage. If your Wrangler login has access to more than one account,
set `CLOUDFLARE_ACCOUNT_ID` before starting.

Small models pick tools less reliably than large ones. If the agent replies
without touching a file, say the task more plainly or try a bigger model in
`MODEL`.
22 changes: 22 additions & 0 deletions examples/pi/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "@example/computer-pi",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Example Worker + Durable Object running a one-shot pi agent against a Workspace, with tools from @cloudflare/computer/tools/pi.",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"typecheck": "tsc --noEmit",
"build:types": "wrangler types"
},
"dependencies": {
"@cloudflare/computer": "*",
"@earendil-works/pi-ai": "^0.85.1",
"zod": "^4.4.3"
},
"devDependencies": {
"typescript": "^6.0.3",
"wrangler": "^4.130.0"
}
}
135 changes: 135 additions & 0 deletions examples/pi/run-local.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// Drive the pi example's agent loop locally, with no Cloudflare account.
//
// The loop, the tool declarations, and the dispatcher are the real ones
// from @cloudflare/computer/tools/pi against a real Workspace. Only two
// things are substituted: pi's own fauxProvider stands in for Workers
// AI, so the tool calls are scripted rather than chosen by a model, and
// an in-memory SQLite storage stands in for Durable Object storage.
//
// node run-local.mjs

import { Workspace } from "@cloudflare/computer";
import { createPiTools } from "@cloudflare/computer/tools/pi";
import { SQLiteTestStorage } from "@cloudflare/dofs/testing";
import {
createModels,
fauxAssistantMessage,
fauxProvider,
fauxText,
fauxToolCall,
} from "@earendil-works/pi-ai";

const MAX_TURNS = 10;

const workspace = new Workspace({ storage: new SQLiteTestStorage() });
// `exec` needs a backend to be declared. There is no shell backend in
// plain node, so calling it fails at the backend — which is after the
// argument handling this script is checking.
const { tools, execute } = createPiTools({
workspace,
shell: { defaultBackend: "shell", backends: { shell: { description: "test shell" } } },
});

const faux = fauxProvider();
const models = createModels();
models.setProvider(faux.provider);
const model = faux.getModel();

// What the model "decides" to do, one reply per turn: write a file,
// read it back, then answer.
faux.setResponses([
fauxAssistantMessage(
[
fauxToolCall("write", {
path: "/workspace/haiku.txt",
content: "durable object\nholds a file across restarts\npatient as a stone\n",
}),
],
{ stopReason: "toolUse" },
),
]);

const messages = [
{
role: "user",
content: "Write a haiku to /workspace/haiku.txt then read it back.",
timestamp: Date.now(),
},
];

let answer = "(no answer)";
for (let turn = 0; turn < MAX_TURNS; turn += 1) {
const reply = await models.complete(model, {
systemPrompt: "You are working in a directory at /workspace.",
messages,
tools,
});
messages.push(reply);

const calls = reply.content.filter((block) => block.type === "toolCall");
if (calls.length === 0) {
answer = reply.content
.filter((block) => block.type === "text")
.map((block) => block.text)
.join("");
break;
}

for (const call of calls) {
const { content, isError } = await execute(call);
console.log(
`turn ${turn}: ${call.name}(${JSON.stringify(call.arguments).slice(0, 60)}) -> isError=${isError} ${JSON.stringify(content).slice(0, 110)}`,
);
messages.push({
role: "toolResult",
toolCallId: call.id,
toolName: call.name,
content,
isError,
timestamp: Date.now(),
});
}

// Script the next reply, now that this turn's tools have run.
if (turn === 0) {
faux.setResponses([
fauxAssistantMessage([fauxToolCall("read", { path: "/workspace/haiku.txt" })], {
stopReason: "toolUse",
}),
]);
} else if (turn === 1) {
faux.setResponses([fauxAssistantMessage([fauxText("Wrote the haiku and read it back.")])]);
}
}

console.log("\nanswer:", answer);

// Prove the tools really touched the workspace, not a mock of it.
const onDisk = await workspace.fs.readFile("/workspace/haiku.txt", "utf8");
console.log("file on disk:", JSON.stringify(onDisk));

// And that a failure comes back as a retryable error result.
const missing = await execute({
id: "x",
name: "read",
arguments: { path: "/workspace/nope.txt" },
});
console.log(
"missing file -> isError=%s %s",
missing.isError,
JSON.stringify(missing.content).slice(0, 80),
);

// The review's item 3: a deliberate null input to a callable backend.
const nulled = await execute({
id: "y",
name: "exec",
arguments: { command: "echo hi", input: null },
});
console.log(
"exec input:null -> isError=%s %s",
nulled.isError,
JSON.stringify(nulled.content).slice(0, 110),
);

await workspace.close?.();
Loading
Loading