diff --git a/README.md b/README.md index 2168758..27cfb54 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,10 @@ To see the pieces working together, start with the examples: through `env.LOADER`. No container. - [`examples/think`](examples/think) — an agent that uses the workspace as its working directory. +- [`examples/tutorial`](examples/tutorial) — the smallest version of + that idea, built up step by step: one endpoint, one file, an agent + that writes markdown on the host and runs `pandoc` on it in the + container. ## Repository layout diff --git a/examples/tutorial/.env.example b/examples/tutorial/.env.example new file mode 100644 index 0000000..f3de44b --- /dev/null +++ b/examples/tutorial/.env.example @@ -0,0 +1,12 @@ +# Copy to .env for local development, or set these as secrets in +# production with `wrangler secret put `. The generated +# worker-configuration.d.ts picks the names up from this file: +# wrangler types --env-file=.env.example +# +# The R2 binding alone can't mint a presigned URL, so the assets +# client needs R2 S3 credentials. Create an R2 API token scoped to +# the bucket and fill in the values below. + +R2_ACCESS_KEY_ID= +R2_SECRET_ACCESS_KEY= +CLOUDFLARE_ACCOUNT_ID= diff --git a/examples/tutorial/.gitignore b/examples/tutorial/.gitignore new file mode 100644 index 0000000..b0f702d --- /dev/null +++ b/examples/tutorial/.gitignore @@ -0,0 +1,3 @@ +.wrangler/ +.env +.dev.vars* diff --git a/examples/tutorial/Dockerfile b/examples/tutorial/Dockerfile new file mode 100644 index 0000000..fab141f --- /dev/null +++ b/examples/tutorial/Dockerfile @@ -0,0 +1,42 @@ +# Container image for the recipe card tutorial. Same wiring as +# examples/think — pull the wsd binary out of the public GHCR image, +# drop it into a slim debian, and run it as PID 1. The :VERSION tag is +# rewritten in lockstep with the rest of the monorepo by +# script/set-versions.mjs. +# +# wsd mounts the workspace at MOUNT_POINT, so a file the host writes +# through the Workspace is on disk for pandoc to read, and the PDF +# pandoc writes syncs back to the durable object. + +FROM ghcr.io/cloudflare/workspace-wsd-linux-x64:0.0.0-alpha.11 AS wsd + +FROM debian:stable-slim + +ARG PANDOC_VERSION=3.10 +ARG TYPST_VERSION=0.15.1 + +# pandoc converts the agent's markdown; typst is the PDF engine, a +# single 30 MB binary instead of the several hundred megabytes a LaTeX +# install would cost. curl is only the download tool for those two. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + fuse3 libfuse2t64 ca-certificates curl xz-utils \ + && curl -fsSL -o /tmp/pandoc.deb \ + "https://github.com/jgm/pandoc/releases/download/${PANDOC_VERSION}/pandoc-${PANDOC_VERSION}-1-amd64.deb" \ + && dpkg -i /tmp/pandoc.deb \ + && curl -fsSL "https://github.com/typst/typst/releases/download/v${TYPST_VERSION}/typst-x86_64-unknown-linux-musl.tar.xz" \ + | tar -xJ -C /tmp \ + && mv /tmp/typst-x86_64-unknown-linux-musl/typst /usr/local/bin/typst \ + && rm -rf /tmp/pandoc.deb /tmp/typst-x86_64-unknown-linux-musl /var/lib/apt/lists/* + +COPY --from=wsd /usr/local/bin/wsd /usr/local/bin/wsd + +ENV PORT=8080 +ENV MOUNT_POINT=/workspace +# FUSE_MOUNT=auto uses the real kernel FUSE backend when /dev/fuse is +# reachable (Cloudflare Containers) and falls back to the userspace +# shim otherwise (wrangler dev). One image works in both places. +ENV FUSE_MOUNT=auto +EXPOSE 8080 + +ENTRYPOINT ["/usr/local/bin/wsd"] diff --git a/examples/tutorial/README.md b/examples/tutorial/README.md new file mode 100644 index 0000000..f47cf17 --- /dev/null +++ b/examples/tutorial/README.md @@ -0,0 +1,360 @@ +# Tutorial: a PDF recipe card agent + +> [!IMPORTANT] +> **PREVIEW ONLY** This package is provided as a preview for feedback only. +> APIs are unstable and the design is subject to change. +> +> Suitable for experiments, exploration and prototypes. It is NOT suitable +> for production use at this time. + +A worker with one endpoint. You post a dish, an agent finds a matching +recipe on [openstove.org](https://openstove.org/recipes), writes a +markdown recipe card, converts it to a PDF with `pandoc`, and answers +with a link to the PDF. + +``` +POST /prompt ──► RecipeAgent + │ fetch_url https://openstove.org/... (host) + │ write /workspace/card.md (host) + │ bash pandoc card.md -o card.pdf (container) + ▼ + R2 ──► signed link, good for a day +``` + +Both halves of that pipeline touch one filesystem, which is the point +of the workspace. The `write` tool runs on the host, in the durable +object, and writes through the `Workspace` into durable object storage. +The container sees the same file on its FUSE mount at `/workspace`, so +`pandoc` reads it as an ordinary file. The PDF `pandoc` writes syncs +back the other way when the `bash` call finishes, so the finished file +can be published straight from the workspace. + +The finished code is [one file](src/index.ts). The rest of this page +builds it from an empty directory. + +## 1. Create the worker project + +```sh +npm create cloudflare@latest -- workspace-tutorial --type=hello-world \ + --lang=ts --no-git --no-deploy -y +cd workspace-tutorial +``` + +Replace the generated `wrangler.jsonc` with the bindings this project +needs: Workers AI for the model, a durable object with a container +attached to it, and an R2 bucket for the finished PDFs. + +```jsonc +{ + "name": "workspace-tutorial", + "main": "src/index.ts", + "compatibility_date": "2026-05-26", + "compatibility_flags": ["nodejs_compat"], + + "ai": { "binding": "AI" }, + + "containers": [ + { "class_name": "RecipeAgent", "image": "./Dockerfile", "max_instances": 5 } + ], + + "durable_objects": { + "bindings": [{ "name": "RecipeAgent", "class_name": "RecipeAgent" }] + }, + + "r2_buckets": [{ "binding": "CARDS", "bucket_name": "recipe-cards" }], + "vars": { "CARDS_BUCKET_NAME": "recipe-cards" }, + + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["RecipeAgent"] }] +} +``` + +Two details matter here. The `containers` entry names the same class as +the durable object binding — one container instance per durable object, +which is how the agent gets a private Linux box. And the workspace +keeps its files in SQLite-backed durable object storage, so the class +has to be in `new_sqlite_classes`. + +Create the bucket now; `wrangler` won't do it for you: + +```sh +wrangler r2 bucket create recipe-cards +``` + +## 2. Install the dependencies + +```sh +npm install @cloudflare/workspace @cloudflare/think agents +``` + +- `@cloudflare/workspace` is the filesystem, the container backend, and + the assets client that publishes to R2. +- `@cloudflare/think` provides the agent loop and its file, shell, and + fetch tools. `agents` provides `getAgentByName` for reaching an + instance by name. + +## 3. Create the Dockerfile + +The container is an ordinary Linux image with one requirement: `wsd`, +the workspace daemon, has to be PID 1. It mounts the workspace at +`MOUNT_POINT` and syncs it with the durable object. Everything else in +the image is yours — here, `pandoc` and a PDF engine for it. + +```dockerfile +FROM ghcr.io/cloudflare/workspace-wsd-linux-x64:VERSION AS wsd + +FROM debian:stable-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + fuse3 libfuse2t64 ca-certificates curl xz-utils \ + && ...install pandoc and typst... + +COPY --from=wsd /usr/local/bin/wsd /usr/local/bin/wsd + +ENV PORT=8080 +ENV MOUNT_POINT=/workspace +ENV FUSE_MOUNT=auto +EXPOSE 8080 + +ENTRYPOINT ["/usr/local/bin/wsd"] +``` + +See [`Dockerfile`](Dockerfile) for the full version, including the +pinned `pandoc` and `typst` downloads. `typst` is the PDF engine +because it is a single 30 MB binary; a LaTeX install would cost several +hundred megabytes of image. + +`FUSE_MOUNT=auto` uses the kernel FUSE backend when `/dev/fuse` is +reachable, which it is on Cloudflare Containers, and falls back to a +userspace shim otherwise, which is what `wrangler dev` gets. One image +works in both places. + +## 4. Give Think a Computer workspace + +The durable object owns a `CloudflareContainerBackend`, which is the +container the workspace is mounted in. `createComputerWorkspace` makes +the Computer that owns the filesystem and gives it Think's workspace +interface. + +```ts +import { + CloudflareContainerBackend, + withWorkspaceContainer, +} from "@cloudflare/workspace/backends/container"; +import { Think } from "@cloudflare/think"; +import { + createComputerWorkspace, + type LocalComputerWorkspace, +} from "@cloudflare/think/experimental/computer"; +import type { DurableObjectStorageLike } from "@cloudflare/workspace"; + +class RecipeBase extends Think {} + +export class RecipeAgent extends withWorkspaceContainer(RecipeBase) { + override workspace: LocalComputerWorkspace; + readonly #backend: CloudflareContainerBackend; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + this.#backend = new CloudflareContainerBackend({ + container: () => this, + workspace: { binding: "RecipeAgent", id: ctx.id.toString() }, + }); + this.workspace = createComputerWorkspace({ + storage: ctx.storage as unknown as DurableObjectStorageLike, + backends: [this.#backend], + }); + } + + // The container dials back in over this route to open its session. + override async fetch(request: Request): Promise { + return new URL(request.url).pathname === "/ws" + ? this.#backend.handleFetch(request) + : super.fetch(request); + } +} +``` + +`withWorkspaceContainer` mixes the container lifecycle into Think, so +the durable object can start and stop its own container. The +`workspace: { binding, id }` pair is how the container finds its way +home: it dials the named binding at that id, which is why `fetch` has to +hand `/ws` to the backend before the base class sees it. + +## 5. Hook the workspace up to the agent + +Think already has file and shell tools. The experimental Computer +workspace makes those tools use the same filesystem as the container: +`write` calls Computer's host-side filesystem, while `bash` calls the +container shell. Think's fetch tool gets an allowlist of one host, so +the agent can read openstove.org and nothing else. + +```ts +override maxSteps = 10; +override fetchTools = { + allowlist: ["https://openstove.org/**"], + followRedirects: "none" as const, + maxModelChars: 64_000, +}; + +override getModel() { + return "@cf/moonshotai/kimi-k2.6"; +} + +override getSystemPrompt() { + return SYSTEM; +} +``` + +There is no `onChatMessage`, tool schema, or filesystem adapter in the +agent. Think assembles its tools and drives the model loop. Nothing +copies files between `write` and `bash`: the write goes +into durable object storage and the container reads it back out of the +mount, and the PDF `pandoc` leaves behind travels the same road in +reverse. + +The system prompt is what turns those three tools into a recipe card: +fetch the sitemap, pick the closest page, read the JSON-LD the page +carries, write the card, run `pandoc`. See `SYSTEM` in +[`src/index.ts`](src/index.ts). + +One method drives a whole turn and publishes the result. +`saveMessages` appends the user's message, runs the turn, and reports +how it ended, so `card` can wait for the agent to finish and then hand +the PDF to the assets client: + +```ts +async card(prompt: string): Promise<{ url: string; summary: string }> { + try { + await this.workspace.fs.mkdir("/workspace", { recursive: true }); + const turn = await this.saveMessages([ + { id: crypto.randomUUID(), role: "user", parts: [{ type: "text", text: prompt }] }, + ]); + if (turn.status !== "completed") { + throw new Error(`Agent turn ${turn.status}: ${turn.error ?? "no detail"}`); + } + const assets = createAssets({ + ws: this.workspace, + bucket: this.env.CARDS, + s3: { + bucket: this.env.CARDS_BUCKET_NAME, + accountId: this.env.CLOUDFLARE_ACCOUNT_ID, + accessKeyId: this.env.R2_ACCESS_KEY_ID, + secretAccessKey: this.env.R2_SECRET_ACCESS_KEY, + }, + }); + const url = await assets.share(PDF, { expiresAfter: LINK_TTL_MS, prefix: "cards" }); + return { url, summary: lastAssistantText(this.messages) }; + } finally { + await this.workspace.close(); + } +} +``` + +`share` streams the file out of the workspace into R2 and signs a GET +for it, so the bucket stays private, the worker never serves the bytes, +and the link stops working after a day. The `finally` block closes the +container session after this one-shot agent has finished. +[`examples/assets`](../assets) uses the same client on its own. + +## 6. Add the export + +The worker itself is the small part: one route, and a durable object +instance per request so every card starts from an empty workspace and +an empty conversation. + +```ts +export { WorkspaceProxy }; + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + if (request.method === "POST" && url.pathname === "/prompt") { + const { prompt } = (await request.json()) as { prompt?: string }; + if (typeof prompt !== "string" || prompt.trim() === "") { + return Response.json({ error: "prompt must be a non-empty string" }, { status: 400 }); + } + const agent = await getAgentByName(env.RecipeAgent, crypto.randomUUID()); + return Response.json(await agent.card(prompt)); + } + + return new Response('POST /prompt {"prompt":"chili con carne"}\n', { status: 404 }); + }, +} satisfies ExportedHandler; +``` + +Three exports have to be reachable from the entrypoint: the default +handler, the `RecipeAgent` class the durable object binding names, and +`WorkspaceProxy`, which carries the container's egress back to the +durable object. `WorkspaceProxy` is re-exported from +`@cloudflare/workspace`; the runtime binds it by name, so it has to +appear in the module graph even though your code never calls it. + +## Running it + +You need Docker running locally. The first build pulls the `wsd` image +from the public GitHub Container Registry and installs `pandoc` and +`typst` on top of a slim debian. + +Presigning needs R2 S3 credentials the binding can't provide. Create an +R2 API token scoped to the bucket, then copy +[`.env.example`](.env.example) to `.env` and fill it in. In production +set the same names as secrets: + +```sh +wrangler secret put R2_ACCESS_KEY_ID +wrangler secret put R2_SECRET_ACCESS_KEY +wrangler secret put CLOUDFLARE_ACCOUNT_ID +``` + +`R2_ENDPOINT` can be set instead of `CLOUDFLARE_ACCOUNT_ID` when the +endpoint isn't the default `https://.r2.cloudflarestorage.com`. + +`worker-configuration.d.ts` is generated from both the bindings and the +credential names, so generate it with the env file: + +```sh +wrangler types --env-file=.env.example +``` + +From this repository's root, install the dependencies, build the +workspace package, and run the tutorial: + +```sh +npm install +npm run build --workspace @cloudflare/workspace +npm run dev --workspace @example/workspace-tutorial +``` + +Then ask for a card: + +```sh +curl -X POST http://localhost:8787/prompt \ + -H 'content-type: application/json' \ + -d '{"prompt":"spagetti boglonese"}' +``` + +```json +{ + "summary": "I have selected the recipe for \"Italian Bolognese Sauce with Thyme.\"", + "url": "https://.r2.cloudflarestorage.com/recipe-cards/cards/...?X-Amz-Signature=..." +} +``` + +The first request is slow: the container has to boot before the first +`bash` runs. The link points at R2 rather than at the worker, so it +works the same whether the worker runs locally or deployed. + +`wrangler deploy` works against any account with Workers AI and +Cloudflare Containers enabled. + +## What this tutorial deliberately doesn't do + +- Check the recipe. The model picks a page and paraphrases it; nothing + verifies the ingredient amounts survived the trip. +- Cache anything. Two requests for the same dish boot two containers + and produce two PDFs. +- Delete the PDFs. The link expires after a day but the object stays + in the bucket; set a lifecycle rule if you care. +- Style the card. `pandoc` defaults, no template. diff --git a/examples/tutorial/package.json b/examples/tutorial/package.json new file mode 100644 index 0000000..e2c00ef --- /dev/null +++ b/examples/tutorial/package.json @@ -0,0 +1,26 @@ +{ + "name": "@example/workspace-tutorial", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Tutorial Worker where an AIChatAgent looks up a recipe, renders a markdown card, and converts it to a PDF with pandoc in a @cloudflare/workspace container.", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "typecheck": "tsc --noEmit", + "cf-typegen": "wrangler types --env-file=.env.example" + }, + "dependencies": { + "@cloudflare/ai-chat": "^0.10.1", + "@cloudflare/workspace": "*", + "agents": "^0.20.1", + "ai": "^6.0.196", + "workers-ai-provider": "^3.1.14", + "zod": "^4.4.3" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260616.1", + "typescript": "^6.0.3", + "wrangler": "^4.96.0" + } +} diff --git a/examples/tutorial/src/index.ts b/examples/tutorial/src/index.ts new file mode 100644 index 0000000..b9ca3dd --- /dev/null +++ b/examples/tutorial/src/index.ts @@ -0,0 +1,165 @@ +// Recipe card tutorial. POST /prompt {"prompt":"spaghetti bolognese"} +// and the agent finds a recipe on openstove.org, writes a markdown +// card into the workspace, converts it to a PDF with pandoc inside the +// container, and answers with a link to the PDF. +// +// POST /prompt ──► RecipeAgent (Think + Computer) +// │ fetch_url openstove.org (host) +// │ write /workspace/card.md (host) +// │ bash pandoc card.md -o card.pdf (container) +// ▼ +// R2 ──► signed link, good for a day +// +// The write and the pandoc run touch one filesystem: the host writes +// through the Workspace, the container sees the same bytes on its +// FUSE mount, and the PDF the container produces is readable back on +// the host once the shell command finishes. +// +// README.md walks through building this file from an empty directory. + +import { Think } from "@cloudflare/think"; +import { + createComputerWorkspace, + type LocalComputerWorkspace, +} from "@cloudflare/think/experimental/computer"; +import { type DurableObjectStorageLike, WorkspaceProxy } from "@cloudflare/workspace"; +import { createAssets } from "@cloudflare/workspace/assets"; +import { + CloudflareContainerBackend, + withWorkspaceContainer, +} from "@cloudflare/workspace/backends/container"; +import { getAgentByName } from "agents"; + +// Carries container egress back to the durable object. The runtime +// binds it by name, so it has to appear in the worker's module graph. +export { WorkspaceProxy }; + +const MARKDOWN = "/workspace/card.md"; +const PDF = "/workspace/card.pdf"; + +// The only host the agent is allowed to fetch. +const RECIPES = "openstove.org"; +const SITEMAP = `https://${RECIPES}/sitemap-0.xml`; + +// How long a shared link stays valid: one day. +const LINK_TTL_MS = 24 * 60 * 60 * 1000; + +const SYSTEM = [ + "You turn a cooking request into a one-page PDF recipe card.", + "", + `1. Find the recipe. \`fetch_url\` ${SITEMAP} lists every recipe page.`, + " Pick the closest match and fetch it; each page carries the whole", + ' recipe in a