Tool Calling
In the previous lesson we learnt that the model is stateless. But the second important truth is that it’s also inert. It cannot read files on the disk, run scripts, or browse the internet. All it can do is emit tokens.
So what can we do to enable it to do all these things? The trick to turn emitting tokens into action is tool calling. How does it work? You tell a tool trained model what functions you offer (as JSON schemas), and when the model wants one it replies with a structured request instead of text. Your code takes this request and executes it, appends the result to the conversation, and then sends the whole thing back:
you: "what's in go.mod?" (role: user)
model: tool_calls: read_file{path: "go.mod"} (role: assistant)
loom: module example.com/loom ... (role: tool) ← your code ran os.ReadFile
model: "It declares module loom on Go 1.26." (role: assistant)
The main takeaway from this lesson is that a tool call is a request, not an action. The model can only ask and receive results from the tool execution. Your dispatch code is the only thing that executes.
With that said, our agent becomes the lesson 1 loop + an inner loop that keeps serving tool calls until the model runs out of things to ask.
Build loom v0.2
Step 1: Extend the types
Messages can now carry tool calls (from the model) or a tool name (from you) and requests carry tool schemas. Add to cmd/loom/main.go:
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolName string `json:"tool_name,omitempty"`
}
type ToolCall struct {
Function ToolCallFunction `json:"function"`
}
type ToolCallFunction struct {
Name string `json:"name"`
Arguments map[string]any `json:"arguments"`
}
type Tool struct {
Type string `json:"type"`
Function ToolFunction `json:"function"`
}
type ToolFunction struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters json.RawMessage `json:"parameters"`
}
There are lot of Tool-somethings here. The part to note is the intentional asymmetry in these types:
- Defining a Tool (
Tool/ToolFunction): This is an outbound description. It is a theoretical capability described using a static JSON Schema. - Invoking a Tool (
ToolCall/ToolCallFunction): This is an inbound request. It is a live, specific invocation where the arguments are delivered as a generic map (native object from Ollama).
Understanding the difference between describing a capability and receiving a request to use it, is the key to mastering tool calling.
Update chatRequest with a Tools field:
Tools []Tool `json:"tools,omitempty"`
And set from a package-level var tools = []Tool{readFileTool} inside the chat() function:
var tools = []Tool{readFileTool}
func chat(messages []Message) (Message, error) {
body, err := json.Marshal(chatRequest{
Model: model,
Messages: messages,
Tools: tools,
Stream: false,
})
Step 2: Define the first tool
We need to define two things in this step: readFileTool describes the capability to the model, and readFile defines the code that executes that capability. Add:
var readFileTool = Tool{
Type: "function",
Function: ToolFunction{
Name: "read_file",
Description: "Read a file and return its contents as text. " +
"Use this whenever you need to see what a file contains.",
Parameters: json.RawMessage(`{
"type": "object",
"properties": {
"path": {"type": "string", "description": "Relative path to the file"}
},
"required": ["path"]
}`),
},
}
func readFile(args map[string]any) string {
path, ok := args["path"].(string)
if !ok {
return "error: read_file requires a string 'path' argument"
}
data, err := os.ReadFile(path)
if err != nil {
return "error: " + err.Error()
}
return string(data)
}
Look at the error path of readFile. It doesn’t return a Go error but rather a string describing the error. For example, if the read_file tool fails because the filename is wrong, the model needs to see the error message saying that in order to correct itself and try again.
Step 3: Your turn
Write the inner loop. Replace the body of the user turn handling in main() with an inner loop implementing this flow:
- Call
chat(conversation)and always append the reply even when it contains a tool call. The model must see its own requests in the conversation history. - If the reply has no tool calls, print
reply.Contentandbreaksince this turn is over. - Otherwise, for each tool call:
- print a trace line like
⚙ read_file(go.mod) - dispatch the tool. Use a switch on the tool name and if the name is unknown return
error: unknown tool...(to the model) - append a
Message{Role: "tool", ToolName: ..., Content: result } - loop - the model may want several rounds of reading before it answers
- print a trace line like
When you ready to validate your implementation or need help, here is the finished implementation.
for {
reply, err := chat(conversation)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
break
}
conversation = append(conversation, reply)
if len(reply.ToolCalls) == 0 {
fmt.Println("\nloom:", reply.Content)
break
}
for _, tc := range reply.ToolCalls {
fmt.Printf(" ⚙ %s(%v)\n", tc.Function.Name, tc.Function.Arguments)
var result string
switch tc.Function.Name {
case "read_file":
result = readFile(tc.Function.Arguments)
default:
result = "error: unknown tool " + tc.Function.Name
}
conversation = append(conversation, Message{
Role: "tool",
ToolName: tc.Function.Name,
Content: result,
})
}
}
Step 4: Test it
The moment of truth. From the repo root:
❯ go run ./cmd/loom
loom v0.2 — chatting with gemma4:e4b-mlx (ctrl-c to quit)
you: What's in my go.mod, and what is the Go version?
⚙ read_file(map[path:go.mod])
loom: The `go.mod` file contains:
* **Module Name:** `example.com/loom`
The Go version specified in the file is **1.26.4**.
Look for line with ⚙, it shows that tool was called.
Step 5: Break it
Now let’s see how it handles an error case. Ask loom: “read the file gomod” (no dot). Here is the error message the tool returns:
❯ go run ./cmd/loom
loom v0.2 — chatting with gemma4:e4b-mlx (ctrl-c to quit)
you: read the file gomod
⚙ read_file(map[path:gomod])
loom: I'm sorry, but I was unable to read the file "gomod". The system returned an error: `error: open gomod: no such file or directory`.
What’s Next
In the next lesson, we’ll add two more tools: list_files so loom can find things and the big one, bash to execute commands via os/exec. We’ll also add a tool registry to make adding tools a snap.
Code
You can find full code on GitHub.