Frontend Labs
⋯

60. Agents in JavaScript: the tool-calling loop

An agent is a loop. The model reads the conversation and either answers or asks to call tools (functions you wrote). You run the tools, append the results, and ask the model again, until it answers. The loop itself is plain JavaScript and the same for every provider. This one is ~60 lines and was tested against a scripted model that deliberately makes a bad call:

// agent.js — a provider-agnostic tool-calling loop.
// model.complete({ messages, tools, signal }) returns an OpenAI-style message:
// { role: "assistant", content, tool_calls? }. WebLLM, Ollama, vLLM and
// llama.cpp's server all speak this format.

export function defineTool({ name, description, parameters, run, needsApproval = false }) {
  return { name, description, parameters, run, needsApproval };
}

// Tiny JSON-Schema subset check. Use Ajv or Zod in production.
function validate(schema, args) {
  if (typeof args !== "object" || args === null) return "arguments must be an object";
  for (const key of schema.required ?? []) if (!(key in args)) return `missing "${key}"`;
  for (const [key, value] of Object.entries(args)) {
    const prop = schema.properties?.[key];
    if (!prop) return `unknown argument "${key}"`;
    if (prop.type && typeof value !== prop.type) return `"${key}" must be ${prop.type}`;
    if (prop.enum && !prop.enum.includes(value)) return `"${key}" must be one of ${prop.enum.join(", ")}`;
  }
  return null;
}

export async function runAgent({ model, tools, messages, approve = async () => false, maxSteps = 6, signal, onEvent = () => {} }) {
  const byName = new Map(tools.map((t) => [t.name, t]));
  const toolSpecs = tools.map(({ name, description, parameters }) => ({ type: "function", function: { name, description, parameters } }));
  const history = [...messages];

  for (let step = 1; step <= maxSteps; step++) {            // a hard cap: agents can loop forever
    signal?.throwIfAborted();
    const reply = await model.complete({ messages: history, tools: toolSpecs, signal });
    history.push(reply);
    if (!reply.tool_calls?.length) {
      onEvent({ type: "final", content: reply.content });
      return { content: reply.content, history, steps: step };
    }
    for (const call of reply.tool_calls) {
      const tool = byName.get(call.function.name);
      let result;
      try {
        if (!tool) throw new Error(`no such tool "${call.function.name}"`);
        const args = JSON.parse(call.function.arguments || "{}");   // arguments arrive as a JSON *string*
        const problem = validate(tool.parameters, args);
        if (problem) throw new Error(`invalid arguments: ${problem}`);
        onEvent({ type: "tool-call", name: tool.name, args });
        if (tool.needsApproval && !(await approve(tool.name, args))) {
          result = { declined: true, reason: "The user declined this action." };
        } else {
          result = await tool.run(args, { signal });
        }
      } catch (err) {
        result = { error: err.message };          // errors go BACK to the model, so it can fix its call
      }
      onEvent({ type: "tool-result", name: call.function.name, result });
      history.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify(result) });
    }
  }
  throw new Error(`Agent stopped after ${maxSteps} steps without a final answer`);
}

The test run's event log shows each design decision working:

tool-call   {"orderId":"A-1001"}
tool-result {"status":"delivered","total":199}
tool-result {"error":"invalid arguments: \"reason\" must be one of damaged, late, other"}   ← model sent "broken"
tool-call   {"orderId":"A-1001","reason":"damaged"}                                        ← …and corrected itself
APPROVE?    refund {"orderId":"A-1001","reason":"damaged"}                                ← human in the loop
tool-result {"ok":true,"orderId":"A-1001"}
final       "Your refund for A-1001 ($199) is on its way."

The rules that make agents safe enough to ship:

  1. Validate every argument against the schema. The model will eventually send garbage. Return the error as a tool result rather than throwing, and it usually fixes itself.
  2. Approve anything irreversible. Payments, deletes, emails and refunds are needsApproval tools. The server must enforce the same checks again, because the browser is not a trust boundary (§65).
  3. Cap the loop: steps, tokens, wall-clock time and money (§66 LLM10).
  4. Give tools the user's permissions, never more (§66 LLM06). A tool calls your API with the user's session, so access control (§65 A01) still applies.
  5. Trace everything. The onEvent stream is your audit log and the feed for the UI (§61).

WebMCP: letting the browser's agent use your page

The Model Context Protocol (MCP) standardizes how agents discover and call tools. WebMCP brings the idea into web pages. A page registers tools, and an agent running in the browser (the browser's own assistant, or an extension) calls them with structured arguments instead of scraping the DOM and clicking buttons. Microsoft and Google are developing it in a W3C community group. Chrome shipped an early preview behind a flag (Chrome 146) and then an origin trial. The current draft hangs the API off document.modelContext, and early builds used navigator.modelContext, so detect both:

const mc = document.modelContext ?? navigator.modelContext;
if (mc?.registerTool) {
  const controller = new AbortController();
  mc.registerTool({
    name: "searchOrders",
    description: "Search the signed-in user's orders by item name or status",
    inputSchema: {
      type: "object",
      properties: { query: { type: "string" }, status: { type: "string", enum: ["delivered", "in transit", "refund requested"] } },
      required: ["query"],
    },
    // Runs in your page, with the user's session and your app's own checks.
    async execute({ query, status }, { signal }) {
      const res = await fetch(`/api/orders?q=${encodeURIComponent(query)}&status=${status ?? ""}`, { signal });
      return { content: [{ type: "text", text: JSON.stringify(await res.json()) }] };
    },
  }, { signal: controller.signal });              // controller.abort() unregisters the tool
}

Because the tool runs in your page, it inherits the user's session and your UI's checks, and users can watch what the agent does. The same rules apply: validate input, and require confirmation in your UI for anything destructive.

Generated from real-life-JS-guide.md · 8 Oct 2026 16:36