Skip to main content

Step 4 of 7: Add a primitive

By the end you will be able to:

  • Choose one platform primitive and wire it into your agent
  • Point at it in your code and say what it does in one sentence
  • Show the agent doing something a plain chat cannot

Use the Cloudflare account on your team card (also on the Workspace page). No account yet? Ask a host: you can read on, but you can't deploy until it is ready.

The curl and PowerShell examples use $DEMO_URL (macOS / Linux) or $env:DEMO_URL (PowerShell): your demo URL from your team card, set once per terminal in Step 1. Open a new terminal later? Set it again.

Why it matters​

  • A chat answers; an agent does something. One primitive is what lets your agent act when nobody is typing, wait for hours, read a live page, answer from your own documents or keep a file. That is the part of a demo people remember.
  • Each primitive replaces something you would otherwise build. A scheduler, a job runner with retries, a search index, a headless browser, a file store. Here each one is a single binding, with nothing to run and no API key to look after.
  • It is the same loop every time. Declare a binding, call it from a tool, check it with one command. Learn it once and the next primitive takes minutes.

A primitive plugs into the starter in two places: a binding in wrangler.jsonc (the bar along the bottom) and a call from tools() in the Durable Object. The Cloudflare platform lane on the right already holds Workers AI and the AI Gateway, which you reach the same way. The primitive you choose is one more product in that lane, reached from tools() through its binding. Click any box to see what it does.

Click a box or an arrow to see what it does.

The concepts​

A primitive is a Cloudflare product your agent uses through a binding. Adding one is always the same four moves: declare the binding in wrangler.jsonc (the starter has commented blocks ready), run npm run types, call it from a tool, then npm run deploy and test on your demo URL. Here is why each part is the way it is:

  • A binding is how your Worker is allowed to use a product. It is a name in wrangler.jsonc that appears as env.<NAME> in your code. There is no API key to store or leak, because the platform connects the two. A product you have not bound cannot be reached from your code at all.
  • npm run types catches typos early. It regenerates env.d.ts from wrangler.jsonc, so a wrong binding name becomes a type error at your desk instead of undefined in the middle of the demo.
  • Some bindings have no local emulator. AI Search and Browser Run always talk to the real service, so their blocks say "remote": true and npm run dev needs your wrangler login. That is also why every test below runs against your deployed demo URL, the same path your demo will use.
  • Use the shared names. Your team has its own account, so there is nothing to collide with, and every team uses the same names: agent-workflow, agent-files, agent-docs, agent-db. The commented blocks in the starter already say so: uncomment them as they are. What you create here lives in your account and counts against its limits only.
  • Choose by the job, not by the product. Each primitive takes one specific chore off your hands. Pick the chore your demo needs and make that one work end to end: two half-working primitives make a worse demo than one that works.

Pick one​

Click a name to jump to its steps. Each one starts with why it exists, so you can check it is the right pick before you build.

OptionThe problem it solvesPick it when you want to showWhat you set up
A. ScheduleAn agent only acts when someone writes to itThe agent working on its own: a morning check, a reminderNothing new
B. WorkflowsA job of several steps falls over if one step fails or the request endsA job that retries, waits or pauses for a personA class and a binding. Your first deploy creates the Workflow
C. AI SearchThe model has never read your documents, so it guessesAnswers that cite your own runbookAn instance, a file and a binding
D. Browser Runfetch returns the HTML the server sent, not what a visitor seesReading or capturing a real pageA binding
E. R2Reports and screenshots are files, not table rowsThe agent leaving a file behindA bucket and a binding
Go deeper: background work, approvals and what else the SDK offers

You do not need any of this to finish the step. It helps you choose well, and shows what to reach for when your agent grows.

Queue, Schedule or Workflow?

  • A queue runs work as soon as it can, in order. Use it for work that should not block the reply but does not need to wait for a time.
  • A schedule runs at a time or on repeat. Cron schedules have a one-minute resolution; this.scheduleEvery(seconds, ...) repeats at a fixed interval and skips a run if the last one is still going. Retries are built in, and the schedule is stored in the agent's own database.
  • A Workflow is for several dependent steps. Each step is retried on its own, the whole job can run for minutes to hours, and it can wait for a person.
  • Cron Triggers run a Worker, not an agent. Prefer a schedule when the work belongs to one agent and its data.

Docs: Schedule tasks · Queue tasks · Cron Triggers

Wait for a person inside a Workflow

  • step.waitForEvent pauses until someone sends an event. The event is sent with instance.sendEvent({ type, payload }), and the type may only use letters, digits, - and _ (a . is rejected). The wait can be from one second up to 365 days, and defaults to 24 hours.
  • A timeout fails the whole instance, unless you catch it. Wrap the wait in try...catch when "no answer" should not end the job.
try {
const ok = await step.waitForEvent<{ approved: boolean }>(
"await approval", { type: "purge-approval", timeout: "1 hour" });
if (ok.payload.approved) await step.do("purge", () => purge(plan));
} catch {
// nobody answered in an hour: stop here, or carry on without the risky step
}
  • Step 5 is the in-chat version of this. Moving the gate into a Workflow lets a colleague approve later, even after the chat is closed.

Docs: Events and parameters · Workers API

Make a Workflow part of the agent

  • AgentWorkflow ties a Workflow to the agent that started it. You start it with this.runWorkflow(...). Inside, this.agent calls back into the agent, reportProgress and broadcastToClients update connected browsers, and waitForApproval pauses until the agent calls approveWorkflow().
  • Why you might want that. The Workflow's progress and result then appear in your chat page, and approval becomes a normal agent method. The plain Workflow in Option B is the simpler start.

Docs: Run Workflows from an agent

Ready-made tools for the browser and for search

  • The Agents SDK can hand your model browser tools. createBrowserTools (from agents/browser/ai) adds tools for Markdown, extraction, links, scraping and running code against a live browser. It needs the Browser Run binding and a Worker Loader binding.
  • AI Search can be bound to one instance. An ai_search block with instance_name binds a single instance and calls env.NAME.search(...) directly, with no get(). It cannot list, create or delete instances, and the instance must exist when you deploy.
  • The namespace binding you used reaches every instance in default. In your team account that is only your own instances. It still reaches all of them, so keep synthetic data in it.

Docs: Browser tools for agents · AI Search as an agent tool · AI Search Workers binding

Build it​

Do one option and only that one (you chose it in Pick one). Every command can be copied as it is. Each command shows the system you picked on the Workspace page; where a command is the same on both, it is shown once. Each option has the same shape: why it exists, set it up, add a tool, deploy, test.

Option A: Schedule (no setup)​

All options

Why this one

  • The problem it solves. A chat agent only acts when someone writes to it. Real jobs ("check this every morning", "tell me when it is done") need the agent to start by itself.
  • Why it is built into the agent. The timer is saved in the agent's own database and wakes it with a Durable Object alarm, so it survives restarts and redeploys. When it fires, the agent runs with its own memory and tools, so there is nothing extra to connect.
  • Pick it when you want the demo to show the agent working on its own: a daily check, a reminder, a follow-up.
  • Watch out. A schedule runs one method with a small payload. For a job of several dependent steps, or one that runs for a long time, use Workflows (Option B).

What you will build. The Agents SDK can wake your agent later with this.schedule(when, callback, payload): when is a delay in seconds, a Date or a cron string. The starter already has the scheduleTask, getScheduledTasks and cancelScheduledTask tools. What it does not do is any real work: its executeTask only logs and sends a message. You will make it check a web page, save the result and tell the chat page.

Prefer to let your coding agent do the typing? Copy this prompt into your agent, in your starter folder.

Prompt for your coding agent
I am on Step 4, Option A (Schedule) of Build with Cloudflare Stockholm. If you have the build-stockholm MCP server, call get_step with step 4.
In src/server.ts: (1) at the top of onStart(), keep the existing code and add a SQL table checks (id INTEGER PRIMARY KEY AUTOINCREMENT, task TEXT NOT NULL, result TEXT NOT NULL, at TEXT NOT NULL). (2) Replace executeTask(description, _task): find the first https:// word in description, fetch it with HEAD (follow redirects), build the result "HTTP <status>, cache: <cf-cache-status or n/a>" (or "failed: <error>"), insert a row into checks, console.log it, and this.broadcast a JSON message { type: "scheduled-task", description, timestamp }. (3) Add a tool listChecks that returns the 10 newest rows.
Run npm run types and npm run deploy. On my demo URL call POST /api/chat with "Use scheduleTask to check https://www.cloudflare.com/ in 60 seconds.", wait 70 seconds, then call it with "Use listChecks to show the latest checks." and show me both replies.
Do not change anything else in wrangler.jsonc, and do not create or delete resources I did not ask for.
When it works, give me one good prompt to try in the chat page and say what I should see.

Paste it into your coding agent: the terminal, the IDE panel or the desktop app all work.

1. Add a table at the top of the existing onStart(). Keep the code that is already there:

onStart() {
this.sql`CREATE TABLE IF NOT EXISTS checks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task TEXT NOT NULL,
result TEXT NOT NULL,
at TEXT NOT NULL
)`;
// ...the code that was already in onStart() stays below
}

2. Replace executeTask, the last method of the ChatAgent class, with this. The schedule calls it by name when the time comes:

async executeTask(description: string, _task: Schedule<string>) {
// 1. Do the work: check the first web address in the task text.
const url = description.split(" ").find((w) => w.startsWith("https://"));
let result = "no web address in the task";
if (url) {
try {
const res = await fetch(url, { method: "HEAD", redirect: "follow" });
result = `HTTP ${res.status}, cache: ${res.headers.get("cf-cache-status") ?? "n/a"}`;
} catch (err) {
result = `failed: ${err}`;
}
}
// 2. Keep the result in the table from onStart().
this.sql`INSERT INTO checks (task, result, at)
VALUES (${description}, ${result}, ${new Date().toISOString()})`;
console.log(`Scheduled task: ${description} -> ${result}`);
// 3. Tell the open chat page (it shows as a toast).
this.broadcast(
JSON.stringify({
type: "scheduled-task",
description: `${description}: ${result}`,
timestamp: new Date().toISOString(),
}),
);
}

3. Add a tool to read the results, inside tools():

listChecks: tool({
description: "Show the results of the scheduled checks, newest first.",
inputSchema: z.object({}),
execute: async () =>
this.sql<{ task: string; result: string; at: string }>`
SELECT task, result, at FROM checks ORDER BY id DESC LIMIT 10`,
}),

4. Deploy, ask for a check in a minute, then read the result:

npm run deploy
macOS / Linux
curl -s -X POST "$DEMO_URL/api/chat" \
-H 'content-type: application/json' \
-d '{"message":"Use scheduleTask to check https://www.cloudflare.com/ in 60 seconds."}'
Or type it in your agent's chat page
Use scheduleTask to check https://www.cloudflare.com/ in 60 seconds.

Open your demo URL in a browser and paste this into the message box instead of running the command above. It is the same agent either way.

Wait about 70 seconds, then:

macOS / Linux
curl -s -X POST "$DEMO_URL/api/chat" \
-H 'content-type: application/json' \
-d '{"message":"Use listChecks to show the latest checks."}'
Or type it in your agent's chat page
Use listChecks to show the latest checks.

Open your demo URL in a browser and paste this into the message box instead of running the command above. It is the same agent either way.

What this does: the first call stores a timer inside your agent. A minute later the agent wakes by itself, runs executeTask and saves a row. You should see: a reply saying the task is scheduled, then a row such as HTTP 200, cache: n/a. Any HTTP status means it ran.

To see the toast, keep the chat page open on your demo URL while the check runs: it appears there even when you scheduled it with curl. npx wrangler tail shows the console.log line either way.

Docs: Schedule tasks · Cron and queues: Queues · Cron Triggers

Option B: Workflows​

All options

Why this one

  • The problem it solves. A job of several steps run inside one request is fragile. If a step fails, the request ends or the Worker restarts, you lose your place, and you would have to build the tracking yourself.
  • Why a Workflow fixes it. It saves each step's result and retries a failed step on its own, so a crash halfway carries on instead of starting over. It runs outside the chat request, and it can sleep or wait for an event such as a person's answer. A Workflow that is waiting does not count toward the account's concurrency limit.
  • Pick it when you want to show a job that retries, waits (a sleep, or a pause for a person) or survives a crash.
  • Watch out. It is a separate class and a new resource in the account, created by your first deploy. It runs outside the chat, so you read its result with a command, or have it report back (see Go deeper).

What you will build. A page watch: check a page, wait 30 seconds, check it again.

Prefer to let your coding agent do the typing? Copy this prompt into your agent, in your starter folder.

Prompt for your coding agent
I am on Step 4, Option B (Workflows) of Build with Cloudflare Stockholm. If you have the build-stockholm MCP server, call get_step with step 4.
In wrangler.jsonc uncomment the workflows block as it is (binding MY_WORKFLOW, name agent-workflow, class_name MyWorkflow). In src/server.ts add and export class MyWorkflow extends WorkflowEntrypoint<Env, { url: string }> whose run() does step.do("check page") with a HEAD fetch returning the status, step.sleep("wait", "30 seconds"), step.do("check again") with the same fetch, and returns { first, second }. Add a tool startPageWatch (input: url) that calls this.env.MY_WORKFLOW.create({ params: { url } }) and returns { started: instance.id }.
Run npm run types and npm run deploy. On my demo URL call POST /api/chat with "Use startPageWatch on https://www.cloudflare.com/", wait 45 seconds, then run npx wrangler workflows instances describe agent-workflow latest and show me the output.
Then ask me to open the Workflows page in my team account dashboard, select the Workflow and the latest instance, and tell you what I see. Explain the step diagram: one box per step, with the status and result of this run, drawn from the code, and why that is useful.
Do not change anything else in wrangler.jsonc, and do not create or delete resources I did not ask for.
When it works, give me one good prompt to try in the chat page and say what I should see.

Paste it into your coding agent: the terminal, the IDE panel or the desktop app all work.

1. Declare it. In wrangler.jsonc, uncomment the workflows block as it is. There is no separate create command: your first deploy creates the Workflow, so a permission problem would show up then.

"workflows": [{
"binding": "MY_WORKFLOW",
"name": "agent-workflow",
"class_name": "MyWorkflow"
}],

2. Add the Workflow class to src/server.ts. Put the import with the other imports and the class under ChatAgent. Its name must match class_name above, and it must be exported:

import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from "cloudflare:workers";

export class MyWorkflow extends WorkflowEntrypoint<Env, { url: string }> {
async run(event: WorkflowEvent<{ url: string }>, step: WorkflowStep) {
const first = await step.do("check page", async () =>
(await fetch(event.payload.url, { method: "HEAD" })).status);
await step.sleep("wait", "30 seconds");
const second = await step.do("check again", async () =>
(await fetch(event.payload.url, { method: "HEAD" })).status);
return { first, second };
}
}

3. Add a tool that starts it, inside tools():

startPageWatch: tool({
description:
"Start a durable page watch: check a page, wait 30 seconds, check it again. " +
"Use when the user asks to watch a page.",
inputSchema: z.object({
url: z.string().url().describe("Full https URL to watch"),
}),
execute: async ({ url }) => {
const instance = await this.env.MY_WORKFLOW.create({ params: { url } });
return { started: instance.id };
},
}),

4. Deploy, start a watch, then look at it:

npm run types
npm run deploy
macOS / Linux
curl -s -X POST "$DEMO_URL/api/chat" \
-H 'content-type: application/json' \
-d '{"message":"Use startPageWatch on https://www.cloudflare.com/"}'
Or type it in your agent's chat page
Use startPageWatch on https://www.cloudflare.com/

Open your demo URL in a browser and paste this into the message box instead of running the command above. It is the same agent either way.

Wait about 45 seconds, then ask Cloudflare how the latest run went (same command on both systems):

npx wrangler workflows instances describe agent-workflow latest

What this does: the tool starts a Workflow instance and returns at once; the Workflow keeps running on its own, outside the chat. describe shows its steps. You should see: a reply with started and an id, then a status with three steps, named check page-1, wait-1 and check again-1. The first shows an output of "200" (the HTTP status). While the middle step sleeps the run shows as Running; after about 30 seconds it shows Completed.

5. See it in the dashboard. Open your Workflows (choose your team account), select agent-workflow, then the latest instance.

You should see: a diagram of your Workflow, drawn from your code, with one box per step in order (check page, wait, check again), and the status and result of this run. Cloudflare generates the diagram for you (it is in beta), so there is nothing to configure.

  • Why it is powerful. You see how the job is built without reading the code, which step it is on right now, which one failed or retried, and what each returned. A loop or a branch in your code appears in the diagram too, and you can collapse it to see the high-level flow or expand it to see every step.
  • Why it helps in the demo. A colleague who does not read TypeScript can follow the job. Start another watch while the dashboard is open, and the wait step is visibly sleeping.

Docs: Workflows

All options

Why this one

  • The problem it solves. The model has never read your documents, so it answers from general knowledge and can sound right while being wrong. Pasting every document into the prompt does not scale.
  • Why AI Search fixes it. It does the whole retrieval chore for you: it splits your files into chunks, indexes them and finds the best matches for a question. Each match comes back with its score and the file it came from, so the agent can cite its source and a person can check it. There is no vector database to run.
  • Pick it when you want answers grounded in a runbook, policy or FAQ, with the source named.
  • Watch out. Files need a little time to index after you upload them. There is no local emulator, so you test on your deployed agent. Searches are metered on your team account, so do not put a search in a loop. Use synthetic documents only.

What you will build. A searchDocs tool over one synthetic runbook.

Prefer to let your coding agent do the typing? Copy this prompt into your agent, in your starter folder. Uploading the document is a dashboard click, so the prompt stops there for you.

Prompt for your coding agent
I am on Step 4, Option C (AI Search) of Build with Cloudflare Stockholm. If you have the build-stockholm MCP server, call get_step with step 4.
Run npx wrangler ai-search create agent-docs --type builtin. Create a synthetic file cache-runbook.md (a short cache purge runbook: check the page status, purge both zones, verify, escalate to on-call after 15 minutes). Then STOP and tell me to upload it in the dashboard under AI Search > agent-docs > Items. Wait until I say it is uploaded, then run npx wrangler ai-search stats agent-docs until Indexed matches and Queued and Processing are 0.
In wrangler.jsonc uncomment the ai_search_namespaces block (binding AI_SEARCH, namespace default, remote true). In src/server.ts add a tool searchDocs (input: question) that calls this.env.AI_SEARCH.get("agent-docs").search({ messages: [{ role: "user", content: question }] }) and returns the top 3 chunks as { source: c.item.key, score, text }.
Run npm run types and npm run deploy. On my demo URL call POST /api/chat with "Use searchDocs: what do we do first when a market page shows old prices?" and show me the reply.
Do not change anything else in wrangler.jsonc, and do not create or delete resources I did not ask for.
When it works, give me one good prompt to try in the chat page and say what I should see.

Paste it into your coding agent: the terminal, the IDE panel or the desktop app all work.

1. Create the instance. The command is the same on both systems. If it is refused, see Stuck? below:

npx wrangler ai-search create agent-docs --type builtin

--type builtin means files you upload yourself, with no R2 bucket (it is also required when the terminal is not interactive). A host registered your account's AI Search token before the event, so you do not need to create one.

2. Make a synthetic document to search:

macOS / Linux
cat > cache-runbook.md <<'EOF'
# Cache purge runbook (synthetic example)
When a market page shows old prices, check the page status first.
Then purge the cache for both zones, and verify the page again.
Escalate to the web platform on-call if it is still wrong after 15 minutes.
EOF

Upload it in the dashboard: AI Search > your instance > Items. (The command line has no upload command.) Your own documents work too, but keep them synthetic.

3. Wait until it is indexed (same on both systems):

npx wrangler ai-search stats agent-docs

You should see a table where Indexed shows your file count and Queued and Processing are 0.

4. Declare the binding. In wrangler.jsonc, uncomment the ai_search_namespaces block. It has no local emulator, so keep "remote": true. It needs compatibility_date 2026-03-27 or later, which the starter already has.

"ai_search_namespaces": [{
"binding": "AI_SEARCH",
"namespace": "default",
"remote": true
}],

5. Add the tool, inside tools():

searchDocs: tool({
description:
"Search the team's documents. Use for any question about our runbooks or policies. " +
"Answer from the results and name the source file.",
inputSchema: z.object({
question: z.string().describe("What to look up"),
}),
execute: async ({ question }) => {
const res = await this.env.AI_SEARCH.get("agent-docs").search({
messages: [{ role: "user", content: question }],
});
return res.chunks.slice(0, 3).map((c) => ({
source: c.item.key,
score: c.score,
text: c.text,
}));
},
}),

6. Deploy and ask a question your document answers:

npm run types
npm run deploy
macOS / Linux
curl -s -X POST "$DEMO_URL/api/chat" \
-H 'content-type: application/json' \
-d '{"message":"Use searchDocs: what do we do first when a market page shows old prices?"}'
Or type it in your agent's chat page
Use searchDocs: what do we do first when a market page shows old prices?

Open your demo URL in a browser and paste this into the message box instead of running the command above. It is the same agent either way.

What this does: the tool asks AI Search for the three best chunks and hands them to the model, which writes the answer from them. You should see: an answer that says to check the page status first and names cache-runbook.md.

Docs: AI Search · Workers binding

Option D: Browser Run​

All options

Why this one

  • The problem it solves. A plain fetch gets the HTML the server sent. Many pages build their content with scripts, so fetch can miss what a visitor actually sees. You may also want a screenshot as evidence.
  • Why Browser Run fixes it. It is a real Chrome that Cloudflare runs for you. One call gives you the page as Markdown, a screenshot or a list of links, with no browser to install, run or scale. Through the binding there is no API token to manage.
  • Pick it when you want the agent to check or read a live page, or keep a screenshot as evidence.
  • Watch out. It is a fresh browser that is signed in to nothing, so use public pages. Browser Run has a rate limit on your team account (it depends on the account's plan), so if calls get refused, wait a moment and try again.

What you will build. A readPage tool that returns a page as Markdown.

Prefer to let your coding agent do the typing? Copy this prompt into your agent, in your starter folder.

Prompt for your coding agent
I am on Step 4, Option D (Browser Run) of Build with Cloudflare Stockholm. If you have the build-stockholm MCP server, call get_step with step 4.
In wrangler.jsonc uncomment the browser binding and keep "remote": true. In src/server.ts add a tool readPage (input: a public https url) that uses the browser binding quickAction to return the page as Markdown. quickAction returns a standard Response whose body is JSON with success and result, so read it before returning. Check the live Browser Run docs for the exact call.
Run npm run types and npm run deploy. On my demo URL call POST /api/chat asking it to use readPage on https://www.cloudflare.com/ and summarise the page. Show me the reply.
Do not change anything else in wrangler.jsonc, and do not create or delete resources I did not ask for.
When it works, give me one good prompt to try in the chat page and say what I should see.

Paste it into your coding agent: the terminal, the IDE panel or the desktop app all work.

1. Declare the binding. In wrangler.jsonc, uncomment the browser line. It has no local emulator, so keep "remote": true. The quickAction call needs compatibility_date 2026-03-24 or later, which the starter already has.

"browser": { "binding": "BROWSER", "remote": true },

2. Add the tool, inside tools(). quickAction returns a standard Response whose body is JSON, { "success": true, "result": "...the Markdown..." }, which is why the tool reads it with .json():

readPage: tool({
description:
"Open a public web page in a real browser and return its text as Markdown. " +
"Use when asked what a page says or shows.",
inputSchema: z.object({
url: z.string().url().describe("Full https URL"),
}),
execute: async ({ url }) => {
const res = await this.env.BROWSER.quickAction("markdown", { url });
const body = (await res.json()) as { success: boolean; result?: string };
return body.success ? (body.result ?? "").slice(0, 3000) : "The page could not be read.";
},
}),

The .slice(0, 3000) keeps the result small, for the same reason as in Step 2: a huge tool result crowds out the model's context. Know what you are cutting: the Markdown starts with a short header (the page title and description) and then the page's menu, so on a big shop page the real content can start thousands of characters in. On a big site's home page the whole page can be tens of thousands of characters, and the first 3,000 are often the header and the menu. Raise the number, or cut the menu off, when you need the body.

3. Deploy and ask about a page:

npm run types
npm run deploy
macOS / Linux
curl -s -X POST "$DEMO_URL/api/chat" \
-H 'content-type: application/json' \
-d '{"message":"Use readPage on https://www.cloudflare.com/ and tell me the page title and the first five headings."}'
Or type it in your agent's chat page
Use readPage on https://www.cloudflare.com/ and tell me the page title and the first five headings.

Open your demo URL in a browser and paste this into the message box instead of running the command above. It is the same agent either way.

What this does: the tool asks Browser Run to open the page and convert it to Markdown, and the model reads what came back. You should see: the real page title and category names (for example Förvaring and Köksutrustning & dukning), taken from the page as it is today, not a generic guess. They are in the first 3,000 characters.

Docs: Quick actions

Option E: R2​

All options

Why this one

  • The problem it solves. Rows in this.sql (Step 3) suit records you query. A report, a screenshot or an export is a file: it can be large, and someone may want to open it outside the agent.
  • Why R2 fixes it. R2 is object storage you reach through a binding. You put a file in under a name and get it back by name, or list files by prefix. There are no egress fees, and nothing is public unless you switch that on. You can also read the file straight from your terminal, which proves it is really stored.
  • Pick it when you want the agent to leave something behind: a report, a screenshot, an export.
  • Watch out. Buckets are not public unless you turn that on, but anything in one is yours to look after: use synthetic files only.

What you will build. saveReport and listReports tools over one bucket.

Prefer to let your coding agent do the typing? Copy this prompt into your agent, in your starter folder.

Prompt for your coding agent
I am on Step 4, Option E (R2) of Build with Cloudflare Stockholm. If you have the build-stockholm MCP server, call get_step with step 4.
Run npx wrangler r2 bucket create agent-files. In wrangler.jsonc uncomment the r2_buckets block as it is. In src/server.ts add two tools: saveReport (inputs: title, body; stores the body under reports/<title>.txt with this.env.BUCKET.put and returns the key) and listReports (lists the keys under reports/).
Run npm run types and npm run deploy. On my demo URL call POST /api/chat to save a short synthetic report and then list the reports. Then read the object back with npx wrangler r2 object get from my terminal and show me all three results.
Do not change anything else in wrangler.jsonc, and do not create or delete resources I did not ask for.
When it works, give me one good prompt to try in the chat page and say what I should see.

Paste it into your coding agent: the terminal, the IDE panel or the desktop app all work.

1. Create the bucket. The command is the same on both systems. If it is refused, see Stuck? below:

npx wrangler r2 bucket create agent-files

When the bucket exists, Wrangler prints a binding snippet and asks "Would you like Wrangler to add it on your behalf?" Answer n (no). You add the binding yourself in the next step, with the name the code uses (BUCKET).

2. Declare the binding. In wrangler.jsonc, uncomment the r2_buckets block as it is:

"r2_buckets": [{
"binding": "BUCKET",
"bucket_name": "agent-files"
}],

3. Add two tools, inside tools():

saveReport: tool({
description:
"Save a short text report as a file and return its name. " +
"Use when the user asks to save or store a report.",
inputSchema: z.object({
title: z.string().describe("Short file name, no spaces"),
body: z.string().describe("The report text"),
}),
execute: async ({ title, body }) => {
const key = `reports/${title.replace(/[^a-z0-9-]/gi, "-")}.txt`;
await this.env.BUCKET.put(key, body, {
httpMetadata: { contentType: "text/plain" },
});
return { saved: key };
},
}),
listReports: tool({
description: "List the saved report files.",
inputSchema: z.object({}),
execute: async () => {
const list = await this.env.BUCKET.list({ prefix: "reports/" });
return list.objects.map((o) => ({ key: o.key, bytes: o.size }));
},
}),

4. Deploy, save a report, list it, then read it back from your own terminal:

npm run types
npm run deploy
macOS / Linux
curl -s -X POST "$DEMO_URL/api/chat" \
-H 'content-type: application/json' \
-d '{"message":"Use saveReport to save a report titled cache-check with the text: Malmo page checked, all fine."}'
Or type it in your agent's chat page
Use saveReport to save a report titled cache-check with the text: Malmo page checked, all fine.

Open your demo URL in a browser and paste this into the message box instead of running the command above. It is the same agent either way.

macOS / Linux
curl -s -X POST "$DEMO_URL/api/chat" \
-H 'content-type: application/json' \
-d '{"message":"Use listReports to list the saved reports."}'
Or type it in your agent's chat page
Use listReports to list the saved reports.

Open your demo URL in a browser and paste this into the message box instead of running the command above. It is the same agent either way.

Then read the file straight from the bucket (use the key listReports showed; same command on both systems):

npx wrangler r2 object get agent-files/reports/cache-check.txt --remote --pipe

What this does: the tool writes an object to your bucket; the last command reads it back without going through your agent, so you know it is really stored. You should see: saved with a key such as reports/cache-check.txt, the same key in the list, and your report text printed by the last command.

Docs: R2

Check it worked​

For the option you chose:

  • A, Schedule: listChecks shows a row that your agent wrote by itself, a minute after you asked, while nobody was typing.
  • B, Workflows: describe shows the run with all three steps, and the dashboard shows the same steps as a diagram. The job ran outside the chat and would have survived a crash.
  • C, AI Search: the answer comes from your document and names cache-runbook.md. Ask something the document does not cover and compare the two answers.
  • D, Browser Run: the reply describes text that is on the page right now, not a guess from the model's memory.
  • E, R2: the last command prints your report straight from the bucket. It is still there after a redeploy.

And for everyone: you can point at the binding in wrangler.jsonc and the tool in src/server.ts and say in one sentence what the primitive does.

Stuck?
  • env.X is undefined, or a type error says it does not exist on Env. The binding name in wrangler.jsonc must match the name in code. Run npm run types and redeploy.
  • A create command or the first deploy is refused (code 10000). Your role in your team account should be Administrator; if it is not, or the product is not enabled on the account, you cannot create that resource. Tell a host, who checks your role and the product in your account. Meanwhile switch to Option A, which needs no new resource.
  • Wrangler asks you to register a workers.dev subdomain when you deploy the Workflow (B). A new account has none and Workflows require one. Choose any name (it is only the account's subdomain; your Worker stays off workers.dev).
  • wrangler ai-search create says there is no AI Search API token (C). AI Search needs one account-level token, registered once per account on the dashboard's AI Search page. It should already be there; if not, tell a host.
  • "Already exists". You already created it in your own account: reuse it with the same name (agent-files, agent-docs, agent-workflow) instead of creating it again. If you ran the commands in another account by mistake, check account_id in wrangler.jsonc.
  • A remote binding errors in npm run dev. AI Search, Browser Run and the AI binding talk to the real service; make sure npx wrangler login is done and account_id is your team account, or test on your demo URL.
  • The model never calls your tool. Same fix as Step 2: name the tool in your prompt ("Use searchDocs: ...") and make its description say when to use it.
  • no such table: checks (A). The CREATE TABLE must be inside onStart(). Check it and redeploy.
  • The schedule never fires, or errors (A). Ask for getScheduledTasks, and check that the callback name is exactly executeTask. A name that does not match a method throws. If you ask for a time of day, say the time zone in your prompt.
  • Workflow not found or class error (B). class_name in wrangler.jsonc must match the exported class exactly, and the class must be exported from src/server.ts. Run npm run types and redeploy.
  • AI Search returns nothing (C). Check indexing with the stats command, and that the instance name in the code is the one you created (agent-docs).
  • Browser Run fails on a page (D). Use a public page and try again. It cannot sign in to anything.
  • Stuck for over ten minutes. Drop to Option A: it needs no setup.
Go further
  • Combine two. A schedule that starts a Workflow, or Browser Run screenshots saved to R2. For the second, remember that quickAction returns a Response, so read it before storing: await this.env.BUCKET.put(key, await res.arrayBuffer(), { httpMetadata: { contentType: "image/png" } }).
  • D1 (a shared SQL database) and KV (fast config) are in the Platform Guide if step 3 was not enough.
  • Sandbox SDK and Containers run code the model wrote; heavy for a three-hour build, fun for a demo. See the Platform Guide.
  • Every primitive in one map: Platform Guide. Optional reading, nothing here depends on it.

Finished step 4?

Checking your team…


Back: Step 3: State and memory. Next: Step 5: Human approval.

Need help? Raise a hand for a host, or ask the Mentor dock (it escalates to a host when unsure). Remote: remote help channel to be confirmed by the event owner.