posts / go

Lesson 2: The Tool Calling

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:

  1. Defining a Tool (Tool / ToolFunction): This is an outbound description. It is a theoretical capability described using a static JSON Schema.
  2. 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.Content and break since 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

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.

RS
Rob Sliwa

Coder | Book Lover | Lifelong Learner

PT
Pawan Tripathi

Writes about infrastructure, agentic coding, and trying to keep things small.