I spent months thinking function calling meant the LLM executes my code. It never does. It asks, I run it. Once that clicked, agents stopped being magic and turned into a while loop I could have written.
The model proposes
Tool use is a while loop, and your code is the hands
My Python for TypeScript devs series ended with a /chat endpoint that could talk. This one is about the week it stopped only talking.
I initially assumed that "function calling" meant the model runs my function in sandbox environment. That wrong in every sense and I think because of that agents felt like magic box.
The model always asks and then we need to run that function.
Here's the most important part of the process. You start by handing the model a list of tools, and a tool is just a JSON description of code you happen to have lying around:
A name, a description, and a JSON Schema for the arguments. Nothing in there is executable and nothing points at my function. The model has no idea list_files is a Python function, and it never finds out.
Now I ask a question with those tools attached:
The response doesn't contain an answer. It contains a request:
That stop_reason is the whole idea. The model stopped mid-thought to ask for something. It picked the tool, filled in the arguments from my schema, then handed control back and waited.
Nothing has happened yet. No directory was read, nothing on my disk was touched. It's a request, and requests don't do anything on their own.
Then I run the function and send the result back as a new message:
The tool_use_id is what pairs the answer to the question. And look at the role on that message: user. There's no special channel for tool output. Results arrive as ordinary user turns in an ordinary transcript, which is why the whole thing works with a stateless API and a Python list.
Call the API again with that longer list, and the model either answers or asks for the next tool.
Put those two halves together and you get the thing most agent posts are quietly describing:
Call the API. If it asked for a tool, run it, append the result, go around again. Otherwise you have your answer.
I sat with that for a while, because I'd been reading about agents for months on the assumption there was a piece I hadn't seen yet. There isn't one. Agents are this loop with more tools.
The tool_use blocks have to go back exactly as they came, or the next request carries a tool_result answering a question that no longer exists in the transcript.
The first is that it's control inversion, and I'd written the shape before without noticing. The model isn't calling into my system. It's closer to a very polite client that can only make requests and never touches anything directly, and my code is the runtime it calls into.
If you've written a reducer, this is that. Something hands you an action describing what it wants, and a switch or a lookup decides what actually happens. Redux never lets an action mutate state either, and for the same reason: keeping the decision and the execution in separate hands is what makes it reviewable.
The second is that the dispatch is a handler map and nothing else. Here's run_tools in full:
A dict from name to function, then a lookup. That's an Express router matching a route to a handler, with block.input playing the part of req.body.
Adding a tool means one new entry in HANDLERS and one in tools. The loop never changes. I didn't design that in and wouldn't have thought to; it falls out of the shape, which is usually a good sign about the shape.
Because I run the code, I own the blast radius.
The model asked for path=".", which is fine. Nothing stops it asking for path="/etc/passwd" or path="../../.ssh". Not the schema, which only promises the argument is a string. Not the description, which the model reads as loosely as it likes. Those arguments are generated text, and generated text can say anything.
So the first thing my file tool does is refuse to leave the working directory:
.resolve() is doing the work here. It collapses .. segments and follows symlinks before the check runs, so ../../.ssh becomes an absolute path that fails is_relative_to. Scanning the raw string for .. would let a symlink walk straight past.
Treat every tool argument like untrusted user input, because that's exactly what it is. The model proposes. My code disposes, and validates, and says no.
Returning an error string instead of raising is deliberate. The model reads that string on the next turn and adjusts, usually by asking for a path inside the project. A raised exception just kills the loop. There's also a first-class way to say it failed, which beats making the model infer failure from prose:
The plumbing was never the hard part. Ten lines and a dict.
The hard part is deciding which tools to trust the model with, and building the guardrail around each one. list_files is easy to reason about. A tool that writes files, or one that spends money, is a different conversation, and the loop gives you no help with it at all. That's judgement, and it's the part of this that a nicer SDK won't abstract away.
The type hints are the middleware
FastAPI, from an Express developer's point of view
The last post in the series. I rebuilt my Express reflexes in FastAPI, found the type hints doing the validation, the docs, and the serializing, then made it stream.
You start it
async/await, but the event loop is not ambient anymore
async/await transfers almost 1:1 from JS, but Python's event loop is not ambient. Two gotchas from wiring up token-by-token streaming.
Stop writing JavaScript in Python
Comprehensions, a reversed join(), and KeyError
List comprehensions, a reversed join(), and KeyError instead of undefined. Small syntax, but the idioms took real unlearning.