My static site got a tool loop — and maybe an agent

September 15, 2026
Loading...

Loading...

Over the last few months, I put together my little chat assistant. It could talk, and that was all it could do. But I want it to be able to do two things (at least):

  • Talk to Bundestakt, an awesome website with a free API. It summarises Bundestag plenary sessions and fact-checks of what was claimed in the debates. Asking what happened in the session on 9 September should get an answer from the actual record over there, not from whatever the model invents.
  • Make an image without leaving the chat. The image generator already had its own page. Describing a picture mid-conversation and then being sent somewhere else to get it is somehow clunky.

For both, the Mistral model has to get outside information and I was unsure how this could work. I had only ever met tool calling from inside LangGraph, in Python. There a tool call and a model call look like the same kind of thing — nodes in one graph, running in one process.

And on this little website it was not clear how to get the information to the model. The website is based on Vike and statically built on deployment. Next to those files sit a handful of serverless functions — small pieces of code that wake up when something calls them and shut down again afterwards. One of them is the paid endpoint that talks to Mistral for me, and it will not help either: it passes my list of tools along to the model and hands the model's reply straight back, without ever running a tool itself.

So the loop that collects the information has to run in the browser. Which, it turns out, is quite straight forward in modern React apps.

The workflow

Let us walk through the mechanism with the following example. I want to ask my model "What did the Bundestag debate on 9 September?" and have the answer come from Bundestakt. Below is a sketch of the full flow, and the rest of the post walks through it step by step.

Browser(the chat page)LLM endpoint→ MistralBundestakt1. messages + tools2. tool_calls: get_sitzungenNothing has happened yet3. GET /api/v1/sitzungen21 sessions, 74 KB4. same array + tool resultthe answer, in prose
One turn. The browser is the only thing that ever calls Bundestakt; the model just asks it to.

Step 1: Send the usual request, plus tools

When you chat with a model, you send it a list of messages — who said what, in order. Tool calling adds a second list next to the first one. I like to think of it as a menu: here are the things I can do for you, here is what each one is good for, and here is what you would have to fill in to order it.

The model itself cannot cook anything on that menu. It can only read it and tell me what it would like. Which means the description of each tool is really important: it is the only thing the model ever learns about a tool. Below you have a code snippet of how it looks in practice.

curl https://llm-agent.fretchen.eu -d '{
  "messages": [
    { "role": "user", "content": "What did the Bundestag debate on 9 September 2026?" }
  ],
  "tools": [{
    "type": "function",
    "function": {
      "name": "get_sitzungen",
      "description": "List Bundestag plenary sessions (Bundestakt), or fetch one session's full detail by slug. Call without a slug first to find the right session, then call again with its slug for details.",
      "parameters": { /* slug, plus von / bis as ISO dates to narrow the range */ }
    }
  }]
}'

Step 2: The model requests the information from the tool

If the model thinks that it requires information from Bundestakt it will tell you so in its request answer. The reply still arrives in the same shape: a choices list, with a message inside it. Normally that message has content, the text you would read on screen. This time content is empty and a tool_calls list has taken its place. And finish_reason, the label saying why the model stopped talking, says "tool_calls" instead of the usual "stop".

The cool thing is that the model does not have to run the tool itself, it only tells us that it wants the specific item get_sitzungen from the tool menu. See the according code snippet of the answer below.

{
  "choices": [
    {
      "finish_reason": "tool_calls",
      "message": {
        "role": "assistant",
        "content": null,
        "tool_calls": [
          {
            "id": "call_a1b2c3d4",
            "type": "function",
            "function": {
              "name": "get_sitzungen",
              "arguments": "{\"von\": \"2026-09-09\", \"bis\": \"2026-09-09\"}"
            }
          }
        ]
      }
    }
  ]
}

Quite importantly, the model has no memory. The only reason the assistant appears to remember anything is that I send the entire conversation again, from the beginning.

Step 3: The web app runs the tool to collect the information

This is for me the cool part of this little React app. The page just reads the request answer from the model, unpacks it and calls Bundestakt itself. An ordinary fetch, the same one any React component makes to load anything — from your browser, straight to bundestakt.de.

Fetch the sessions, then keep only the few fields an answer actually needs:

// tools/bundestakt.ts
const res = await fetch("https://www.bundestakt.de/api/v1/sitzungen");
const { sitzungen } = await res.json();

return sitzungen.map((s) => ({
  slug: s.slug,
  datum: s.datum,
  kernthema: s.kernthema,
  schlagzeilen: s.in30Sekunden.map((p) => p.titel),
  url: s.url,
}));

Bundestakt sends 74 KB across 21 sessions; keeping only date, topic and headlines brings it to 7.3 KB. And since the whole conversation goes back to the model every time, a fat result is not paid for once — it is paid for again with every later message of the same turn.

Step 4: Contact the model again, with the collected information

Now the same list of messages goes back to the same endpoint, with two things added to the end: the model's own order, handed back word for word, and the answer to it, tagged with the id the model gave that order. That answer is a third kind of message — not from the person, not from the model, but role: "tool".

{
  "messages": [
    { "role": "user", "content": "What did the Bundestag debate on 9 September 2026?" },
    {
      "role": "assistant",
      "content": null,
      "tool_calls": [{ "id": "call_a1b2c3d4", "type": "function", "function": { "name": "get_sitzungen", "arguments": "{\"von\": \"2026-09-09\", \"bis\": \"2026-09-09\"}" } }]
    },
    {
      "role": "tool",
      "tool_call_id": "call_a1b2c3d4",
      "content": "{\"status\":\"ok\",\"sitzungen\":[{\"slug\":\"21-92-2026-09-09\",\"datum\":\"2026-09-09\",\"kernthema\":\"Haushalt 2027 und der Schock von Sachsen-Anhalt\",\"schlagzeilen\":[\"Weidel greift an, Merz kontert\",\"140 Milliarden fuer Verteidigung\",\"Gekuerzt wird bei den Familien\"],\"url\":\"https://www.bundestakt.de/sitzung/21-92-2026-09-09\"}]}"
    }
  ],
  "tools": [ ... same as before ... ]
}

Same endpoint, same shape as step 1. This time the model decides that it can answer and writes prose, finish_reason comes back as "stop", and the turn is over. If it had wanted the full record it would have ordered again, with slug this time, and the list would have grown by two more messages.

This is the bit I had wrong for a long time: nothing is held open between the two sides. The list just grows and gets sent again.

Which means the loop is a for loop with a break in it. Two things to watch: the list being pushed to at the bottom, and the break that fires the moment the model finally writes words instead of ordering something.

for (let hop = 0; hop < MAX_HOPS; hop++) {
  const offered = offeredTools.filter((t) => !failedTools.has(t.function.name));

  const data = await payAndSend(convo, {
    tools: offered.length > 0 ? offered : undefined,
  });

  const choice = data.choices?.[0];
  const toolCalls = choice?.message.tool_calls;

  if (choice?.finish_reason !== "tool_calls" || !toolCalls?.length) {
    finalContent = choice?.message.content ?? null;
    break;
  }

  convo.push(choice.message); // the assistant turn: content null, tool_calls intact

  for (const call of toolCalls) {
    const { result, recoverable } = await runToolCall(call);
    if (result.status !== "ok" && !recoverable) {
      failedTools.add(call.function.name);
    }
    convo.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify(result) });
  }
}

That is the real one, minus its comments. One trip round that loop is a hop — one journey to the model and back, paid for separately from the last. MAX_HOPS is 4, because a model that never stops ordering would otherwise bill me in a circle. Four is not a principle; it is the smallest number that fits the longest flow I have, which is list → detail → answer with one hop spare.

Four tools, two shapes

I have added four tools by now.

  • get_sitzungen — lists Bundestag plenary sessions, or fetches one session in full.
  • search_claims — searches fact-checked statements MPs made in debates, with the verdict.
  • get_analytics — this site's own visitor numbers. Only I can use this one.
  • generate_image — makes a picture from a description, for seven cents.

The first three are all the same shape: fetch something, throw most of it away, hand back the rest.

generate_image is a bit more complex, because it spends real money — so the model ordering it does not make it happen. Instead, the code puts a confirmation card on screen with the prompt, the size and the price, and then the loop simply stops at that line and waits. It can wait indefinitely. Whatever the person decides — confirm, rewrite the prompt, cancel — comes back to the model as a status like user_declined, and the model writes the sentence explaining it.

How fancy is this ? Is this an agent? Do I need MCP?

Given the addition of decision logic I wonder, if this is already a little agent. On one hand, it picks whether to act, which tool to use, and what to fill in. It reads the result and decides what comes next. It chains: find the session, fetch its detail, then answer. By most working definitions that is an agent.

On the other hand, it also forgets everything the moment the turn ends. Those tool messages live in a list that exists only while one message is being answered, and then it is thrown away — so the model's own closing sentence is its entire memory of having generated an image. It follows no plan that my code owns. It stops after four hops. And the whole thing is about forty lines.

So, it is likely a baby agent...

Interestingly, the whole MCP seems to be over-engineering for me at this stage. From what I understand, it is an agreed format for describing tools and reaching them, so that a tool written by one person can plug into a chat program written by another. Nice standard, but not clear that it is helpful here.

Where that leaves it

In summary, the assistant can now look up a Bundestag session and get the required information without leaving the chat. Both of those turned out to be the same for loop, running in a tab, on a site that is nothing but files on a server.

It runs on the assistant page if you want to watch a list grow.

Loading reactions...

Comments

Loading comments...