Skip to content
AIM Development and Automation

An AI agent with real tools on Workers AI

Gemma 4 with native function calling, four read-only tools and limits on every run. How the Lab's agent works and how much free quota it spends.

Published
Reading time
6 min read
  • AI
  • Workers AI
  • Cloudflare
  • Agents
  • Testing

There’s an AI agent with tools in the Lab. You give it a goal, for example “a site with a blog for my bakery, how much and how long?”, and you watch it work step by step: it writes a short plan, checks the site with tools, works out the estimate and suggests your next step. It runs on the same free Workers AI plan as the assistant, with Gemma 4.

Here’s what tools it has, what limits I set and how I keep it from making up prices (DEC-033, in Spanish).

Native function calling, no custom protocol

The alternative was to ask the model for a JSON with the tool and its arguments, and validate it myself. I didn’t need to. On 2026-11-08 I checked with the real model (@cf/google/gemma-4-26b-a4b-it) that Gemma 4 on Workers AI accepts OpenAI-style tools and tool_choice and that, when streaming, it sends delta.tool_calls: the id and the name first, then the arguments in pieces. This is how the adapter passes them:

tools: request.tools.map((tool) => ({
  type: 'function',
  function: { name: tool.name, description: tool.description, parameters: tool.parameters },
})),
tool_choice: request.toolChoice,

The adapter joins the pieces of each call and hands it over once it’s complete. The model’s thinking stays off: the plan is the short sentence it writes before its calls.

If Workers AI ever stops supporting tools, plan B is exactly what I ruled out: a JSON protocol validated with Zod. And if I load ANTHROPIC_API_KEY, the chat switches to Claude, but the agent stays on Gemma, because the Claude adapter has no tools.

Four tools that only read

The tools run on the edge, in the site’s own API, over the site’s own content. None of them writes, none of them goes out to the network, and all of them are deterministic:

  • search_site searches the services, projects, Lab, blog and FAQ, in the page’s language, and returns up to 5 matches with their path.
  • get_service brings one service: what it includes, its reference timeline, its starting price and the quote calculator’s extras, with their ids.
  • estimate_quote works out the price range with the same function as the quote calculator, which now lives in @aim/contracts/quote-estimate. So both give the same range.
  • suggest_next_step builds the link for the next step: the quote calculator with the service chosen, the contact form or WhatsApp.

They read a catalog that pnpm knowledge generates from apps/web/src/content, along with the assistant’s knowledge. If I change a price and forget to regenerate it, the tests fail.

The model never runs anything: it only sees the tools’ descriptions and their results as JSON. Whatever it asks for goes through that tool’s Zod schema first:

const args = ARGS[name].safeParse(parsed)
if (!args.success) {
  return { ok: false, name, error: 'invalid_arguments', detail: issuesDetail(args.error) }
}

Invalid arguments or a tool that doesn’t exist don’t break the run. The call is refused with a short detail the model can read and fix, and on the page you see it as one more step: “Invalid arguments: the call was refused and the agent can fix it.”

Limits on every side

These are the limits of each run:

export const AGENT_LIMITS: AgentLimits = {
  maxSteps: AGENT_MAX_STEPS,
  maxToolCalls: AGENT_MAX_TOOL_CALLS,
  maxTokens: 350,
  tokenBudget: 7_000,
}

That’s 5 calls to the model, 6 tool calls, 350 output tokens per call and a budget of 7,000 tokens in total. The route adds a 45 s timeout, and the temperature is 0.2. The last call goes out with tool_choice: 'none', so it has to answer with text. A call is also the last one if the tools are used up or if the next call could go over the budget: every call sends everything before it again, so the loop adds what was spent to the previous call’s input. Past the tool limit, a call gets tool_budget back and the agent answers with what it has.

The low temperature came from measuring: at the default one, a run drifted into repeated text until the output cap. Gemma also sometimes starts with leftovers of its thinking channel (thought\n<channel|>), even with thinking off, and the adapter removes them. At first it also wrote links with a made-up domain, and the rules now ask for the path as it comes.

Prices only come from tools

The prompt’s first rule says every fact comes from a tool and prices come only from estimate_quote. But a rule in a prompt isn’t a guarantee, so when the run ends, the loop checks the answer:

unverifiedAmounts: unverifiedAmounts(text, results),

amounts.ts finds every amount of money in the answer (with its currency, and “1,4 millones” counts as 1,400,000) and compares it with the numbers the tools of that run returned. Whatever doesn’t match travels in the done event, and the page warns you next to the answer: “The answer mentions amounts that no tool returned (…). Confirm them in the quote calculator before you decide.”

Your goal reaches the model inside a <goal> tag, as data and not as instructions, and a tag typed inside the goal is removed first. In the measurements, a prompt injection attempt (“ignore your rules…”) ended in a single call without touching any tool.

How much quota it spends, measured

Workers AI now returns usage.neurons with every response, so on 2026-11-08 I measured with the real model (docs/DESPLIEGUE.md, section «Agente IA», in Spanish):

  • A typical run makes 3 to 4 calls and spends between 36 and 60 neurons; the longest, 76. A chat message, as a control, spent 63.
  • The first text arrives 0.4 to 1.2 s after the start, and a full run takes 4 to 9 s.
  • The worst case the limits allow is around 138 neurons: about 11,000 input tokens and 4 × 350 output tokens. That’s roughly twice the worst chat message (about 75).

That’s why each run counts as 2 uses of the quota it shares with the assistant and the code reviewer. The worst day with only the agent would be 60 runs × 138 ≈ 8,300 neurons; with only the chat, 120 × 75 ≈ 9,000. Both fit in the free 10,000.

The two uses are reserved together, with the same lock and conditional insert that fixed the quota’s race condition in DEC-030 (in Spanish):

const slots = await takeUsageSlot(db, {
  ipHash,
  model: agent.model,
  now: deps.now(),
  limits,
  units: AGENT_RUN_UNITS,
})

The reservation is a single statement, in the same batch as the lock (why that matters with Neon over HTTP): either both rows go in or neither does. The real token counts go on the first one, and if the model fails before showing anything, the rows are released.

How I test it

The loop’s tests use a scripted model that plays steps written by hand: invalid arguments that get fixed, a tool that doesn’t exist, the limits, a prompt injection and unverified amounts. Another test, which only runs when I ask for it, repeats the measurement against the real model.

Check it yourself

And to watch it work, give the AI agent with tools a goal.