Build a multi-agent app with tools on Cloudflare, all in the browser. A 35-minute workshop lab by Salman Shaikh, MONARC STUDIOS. It follows social-agents-lab.
You ask a question. One main agent decides which tools to call, and two small agents do narrow jobs behind those tools: one reads web pages, one fact-checks the answer.
flowchart LR
Q["Question<br/>POST /ask"] --> M["Main agent<br/>Llama 4 Scout<br/>picks tools, max 6 turns"]
M -- "wikipedia(topic)" --> W["Plain code<br/>Wikipedia API"]
M -- "read_page(url)" --> S["Small agent 1<br/>Summariser<br/>Llama 3.1 8B"]
M -- "check_answer(draft)" --> F["Small agent 2<br/>Fact-checker<br/>Llama 3.1 8B"]
W --> M
S --> M
F --> M
M --> A["Answer + every tool call"]
- Tools: a tool is a name, a description and its inputs. The model never runs code: it asks for a tool by name, your Worker runs the function and sends the result back.
- The main agent runs a loop: ask the model, run the tools it picks, add the results, ask again. It stops when the model answers without a tool, or after 6 turns.
- Small agents behind tools:
read_pagefetches a page and hands it to a summariser agent;check_answerhands the draft to a fact-checker agent. To the main agent they look like any other tool. - Big model, small models: the main agent needs a model that can call tools (Llama 4 Scout). The small agents only rewrite text, so the cheap 8B model does.
Stack: Cloudflare Workers, Blaze, Workers AI with function calling and Wrangler, inside GitHub Codespaces. Nothing to install on your laptop.
-
Create a free GitHub account and a free Cloudflare account.
-
Click Open in GitHub Codespaces above, then Create codespace. Use your personal GitHub account (see the free allowance below). Wait for
npm installto finish. -
In the codespace terminal, log in to Cloudflare:
npx wrangler login --device
It prints a URL and a code. Open the URL in any browser, check the code matches, and click Allow.
-
Check it worked with
npx wrangler whoami, then close the tab. The codespace stops on its own and resumes in seconds at the workshop.
Plain npx wrangler login waits for a callback on localhost:8976, which your browser can't reach inside a codespace. The device flow needs Wrangler 4.119.0 or newer; this repo pins it.
Everything happens in src/index.ts. Keep npx wrangler dev running: it reloads every time you save. The test page lists every tool call the agent made, so you can watch it work.
Behind? Copy the finished file for any step and keep going, for example:
cp steps/step-3.ts src/index.ts-
Open your codespace and run:
npx wrangler dev
You should see
Ready on http://0.0.0.0:8787. -
Open the Ports tab, find 8787 · Research agent test page and click the globe icon (Open in Browser).
-
Click Ask. You see a placeholder answer and one fake tool call: the route is a stub with no AI yet.
A tool has two halves: a definition the model reads (name, description, input) and a function your code runs. The main agent runs a loop around them.
Replace the Step 1 stub route, everything between the app.get("/", …) line and the export line, with:
// Step 2 · the main agent: a bigger model that can call tools
const MAIN = "@cf/meta/llama-4-scout-17b-16e-instruct";
const SYSTEM = "You are a research assistant. Use the tools to find " +
"facts; never answer from memory. Say where each fact came from.";
// Step 2 · a tool = a name, what it does, and the input it needs
const tool = (name: string, description: string, input: string) => ({
name, description, parameters: { type: "object", required: [input],
properties: { [input]: { type: "string", description: input } } } });
const tools = [
tool("wikipedia", "Look up a topic on Wikipedia", "topic"),
];
// Step 2 · a tool is plain code: the opening section of a Wikipedia page
async function wikipedia(topic: string) {
const url = "https://en.wikipedia.org/w/api.php?action=query" +
"&format=json&formatversion=2&prop=extracts&exintro=1" +
"&explaintext=1&redirects=1&titles=" + encodeURIComponent(topic);
const res = await fetch(url,
{ headers: { "user-agent": "research-agent-lab (workshop)" } });
const data = await res.json<{ query: { pages: { extract?: string }[] } }>();
return data.query.pages[0].extract || `No Wikipedia page for "${topic}".`;
}
// Step 2 · run the tool the main agent picked
type Args = Record<string, string>;
type Step = { tool: string; input: Args; output: string };
async function runTool(env: Env, name: string, args: Args,
question: string, steps: Step[]) {
if (name === "wikipedia") return wikipedia(args.topic);
return `Unknown tool: ${name}`;
}
// Step 2 · the loop: ask the model, run the tools it picks, repeat
type Call = { name: string; arguments: unknown };
type Reply = { response?: string; tool_calls?: (Call & { function?: Call })[] };
app.post("/ask", async (req, res) => {
const { question } = await req.json<{ question: string }>();
const messages: { role: string; content: string; name?: string }[] = [
{ role: "system", content: SYSTEM }, { role: "user", content: question }];
const steps: Step[] = [];
for (let turn = 1; turn <= 6; turn++) { // a step limit: no endless loop
const out = await req.env.AI.run(MAIN, { messages, tools }) as Reply;
if (!out.tool_calls?.length) return res.json({ answer: out.response, steps });
for (const call of out.tool_calls) {
const fn = call.function ?? call; // models return either shape
const args = (typeof fn.arguments === "string"
? JSON.parse(fn.arguments) : fn.arguments) as Args;
const output = await runTool(req.env, fn.name, args, question, steps)
.catch((err) => `The tool failed: ${err}`);
steps.push({ tool: fn.name, input: args, output });
messages.push({ role: "assistant", content: JSON.stringify(call) },
{ role: "tool", name: fn.name, content: output });
}
}
res.json({ answer: "Stopped after 6 turns without an answer.", steps });
});Save, then click Who founded Cloudflare…. The steps list shows the agent calling wikipedia, then answering from what it found.
A web page is too long to hand to the main agent. A small agent reads it and returns 5 bullets.
Add this block above // Step 2 · run the tool the main agent picked:
// Step 3 · a small agent = a small model with one job
const SMALL = "@cf/meta/llama-3.1-8b-instruct-fp8-fast";
async function agent(env: Env, role: string, input: string) {
const out = await env.AI.run(SMALL, { messages: [
{ role: "system", content: role },
{ role: "user", content: input } ] });
return (out as { response: string }).response.trim();
}
// Step 3 · small agent 1, the summariser, works behind read_page
async function readPage(env: Env, url: string, question: string) {
const html = await (await fetch(url)).text();
const text = html.replace(/<(script|style)[\s\S]*?<\/\1>|<[^>]+>/g, " ")
.replace(/\s+/g, " ").slice(0, 6000);
return agent(env, "Summarise this page in 5 short bullets that " +
"help answer: " + question, text);
}Then register the tool in two places: one line in tools, one line in runTool:
tool("read_page", "Read a web page and summarise it", "url"), if (name === "read_page") return readPage(env, args.url, question);Try How do I install Blaze?…: the agent calls read_page, and the summariser's bullets come back as the tool's result.
Before answering, the main agent sends its draft to a fact-checker, which compares it with everything the tools found.
Add this block above // Step 2 · run the tool the main agent picked:
// Step 4 · small agent 2, the fact-checker, works behind check_answer
function checkAnswer(env: Env, draft: string, steps: Step[]) {
const notes = steps.filter((s) => s.tool !== "check_answer")
.map((s) => s.output).join("\n\n");
return agent(env, "Reply OK if every claim in the draft is backed " +
"by the notes. If not, list the claims that are not.",
`Notes:\n${notes}\n\nDraft: ${draft}`);
}Register it the same way, one line in tools and one in runTool:
tool("check_answer", "Fact-check your draft answer", "draft"), if (name === "check_answer") return checkAnswer(env, args.draft, steps);Then tell the main agent to use it: replace the SYSTEM lines with:
const SYSTEM = "You are a research assistant. Use the tools to find " +
"facts; never answer from memory. Say where each fact came from." +
" Before you answer, call check_answer with your draft.";Ask again: the last tool call is now check_answer, and the answer comes after it.
-
On the test page, try the three sample questions. Answers and tool order vary between runs: the model decides.
-
Put it online:
npx wrangler deploy
On a brand-new Cloudflare account, Wrangler may ask you to pick a
workers.devsubdomain first. -
Open the URL it prints,
https://research-agent.<your-subdomain>.workers.dev. The same test page is now live.
The finished file is steps/step-4.ts: 100 lines, comments included.
| What | Free | Notes |
|---|---|---|
| GitHub Codespaces | 120 core hours + 15 GB-month storage a month (GitHub Free, personal accounts) | The default 2-core machine uses 2 core hours per hour, so about 60 hours. With no payment method on file, usage stops at the limit: you are never billed. Codespaces owned by an organization get no free allowance, so open it from your personal account. |
| Cloudflare Workers | Free plan | workers.dev URL included. |
| Workers AI | 10,000 Neurons a day, every account, reset at 00:00 UTC | On the Workers Free plan, calls past the limit fail with an error: no bill. Llama 4 Scout (main agent) costs $0.27 / $0.85 per million input / output tokens on a paid plan; Llama 3.1 8B fp8-fast (small agents) $0.045 / $0.384. Our estimate: one question uses roughly 150 to 250 Neurons, so about 40 to 60 questions a day for free. |
Neither model needs a paid plan. Kimi, GLM and DeepSeek V4 models do, so don't switch the main agent to one of them for the lab.
- The agent answers without calling a tool. Models sometimes skip tools. The system prompt says "never answer from memory"; ask again, or name the source in the question ("look it up on Wikipedia").
No Wikipedia page for …The tool needs a page title, likeEiffel Tower, not a question. The agent usually retries with a better title on its next turn.The tool failed: …The fetch threw, for example on a bad URL or a site that blocks Workers. The agent sees the error and can try something else.Stopped after 6 turnsThe step limit did its job: the agent kept calling tools without answering. Ask a narrower question.- The login page never comes back to the codespace. Use
npx wrangler login --device, not plainwrangler login. Fallback: an API token (template Edit Cloudflare Workers, plus the Workers AI permission), thenexport CLOUDFLARE_API_TOKEN=<your token>in the terminal. - Port 8787 isn't in the Ports tab. Click Forward a Port and type
8787. - An error about the daily free allocation. You've used today's 10,000 Neurons. They reset at 00:00 UTC.
The loop follows Cloudflare's own runWithTools helper in @cloudflare/ai-utils: after each tool call it adds an assistant message holding the call, then a tool message with the result, and asks the model again. Models return a tool call either as { name, arguments } or as { function: { name, arguments } }, with arguments as an object or a JSON string; the loop accepts all of these.
- Your app is public, and every question spends your free Neurons. Take it down with
npx wrangler delete. - Stop or delete the codespace at github.com/codespaces.
- Next steps: give the main agent memory with the Agents SDK, run long research jobs with Workflows, and log every model call with AI Gateway.
- Workers AI function calling: https://developers.cloudflare.com/workers-ai/features/function-calling/traditional/
- Llama 4 Scout on Workers AI (function calling, 131,000-token context): https://developers.cloudflare.com/workers-ai/models/llama-4-scout-17b-16e-instruct/
- Workers AI pricing: https://developers.cloudflare.com/workers-ai/platform/pricing/
- Wikipedia extracts API (lead section as plain text): https://www.mediawiki.org/wiki/Extension:TextExtracts
- GitHub Codespaces billing: https://docs.github.com/en/billing/concepts/product-billing/github-codespaces
- Wrangler device login (Wrangler 4.119.0): https://developers.cloudflare.com/changelog/post/2026-08-04-wrangler-login-device-flow/
- Blaze docs: https://blaze.monarcpro.app/