posts / go

Lesson 3: The Tool Suite

The Tool Suite

In the previous lesson we added tool calling capability and the first tool read_file. In this lesson we’ll add two new tools: list_files and bash. But before we do that, we need to do some code cleanup.

In the main.go file, the tool selection was dispatched with a switch statement. This is fine for a small number of tools but when the number of tools becomes large, the dispatch code will become a long list of cases. And what if the tools could be added dynamically?

We need a better approach.

Step 1: Tool Registry

We’ll make each tool a single value that pairs its schema with its implementation, so registering it is one line:

type ToolDef struct {
	Tool Tool
	Run  func(args map[string]any) string
}

var registry = []ToolDef{readFileDef, listFilesDef, bashDef}

Step 2: Your turn

Refactor the code to use the registry instead of a switch statement. Make sure the behavior is the same and if the tool is not found the error message "error: unknown tool ..." is still returned.

When you’re ready to validate your approach or need help, here is the finished code:

In chat():

func chat(messages []Message) (Message, error) {
	body, err := json.Marshal(chatRequest{
		Model:    model,
		Messages: messages,
		Tools: func() []Tool {
			tools := make([]Tool, len(registry))
			for i, def := range registry {
				tools[i] = def.Tool
			}
			return tools
		}(),
		Stream: false,
	})
.
.
.

and in main():

.
.
.
			for _, tc := range reply.ToolCalls {
				fmt.Printf("  ⚙ %s(%v)\n", tc.Function.Name, tc.Function.Arguments)
				var result string
				var toolDef ToolDef
				var toolFound bool
				for _, def := range registry {
					if def.Tool.Function.Name == tc.Function.Name {
						toolDef = def
						toolFound = true
					}
				}

				if toolFound {
					result = toolDef.Run(tc.Function.Arguments)
				} else {
					result = "error: unknown tool " + tc.Function.Name
				}
.
.
.

Step 3: list_files

loom can read a file but it cannot discover what files exist. The list_file tool gives it this capability. The mechanics are very much the same as for reading the file and only the standard library is needed:

var listFilesDef = ToolDef{
	Tool: Tool{
		Type: "function",
		Function: ToolFunction{
			Name: "list_files",
			Description: "Recursively list files under a directory. " +
				"Use this to discover what exists before reading files.",
			Parameters: json.RawMessage(`{
				"type": "object",
				"properties": {
					"path": {"type": "string", "description": "Directory to list; defaults to the current directory"}
				}
			}`),
		},
	},
	Run: listFiles,
}

func listFiles(args map[string]any) string {
	dir, _ := args["path"].(string)
	if dir == "" {
		dir = "."
	}
	var b strings.Builder
	err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if d.IsDir() && d.Name() == ".git" {
			return filepath.SkipDir
		}
		if !d.IsDir() {
			b.WriteString(p + "\n")
		}
		return nil
	})
	if err != nil {
		return "error: " + err.Error()
	}
	return b.String()
}

Notice that the path is optional as the schema has no required option. The code handles this by defaulting missing/non-string to “.” current folder. The output of this tool is the file listing, and all that goes into the context. So if there are a lot of files this will have an impact on how quickly your context fills.

Step 4: bash - the universal tool

A shell tool makes every other tool optional. cat reads files, ls lists them, sed edits them. However, coding agents still ship dedicated read/list/edit tools.

Why?

Dedicated tools give reliability. Structured arguments beat the quoting gymnastics shell commands might require. The tailored output of these tools also provides token efficiency. And there is also a security aspect. You can auto allow read_file forever but bash needs judgment.

Step 5: Your turn again

This is the main event. Implement bashDef / bashTool in the following manner:

  • Argument is a required string command. Run it using bash -c with os/exec.
  • Use exec.CommandContext with a context.WithTimeout of 60s. The model cannot press Ctrl-C, so the timeout guarantees that the command will not hang forever.
  • Capture output with CombinedOutput(). If the model cannot see output from stderr it will miss the results of many commands.
  • Don’t treat a non-zero result as a failure. This is information you need to pass to the model: return the output plus "\nerror: " + err.Error() which names the exit code.
  • In case of empty output, return "(no output)". The model may take silence as sign of breakage.
  • Description for the schema should be something like: “Execute a shell command and return its combined stdout and stderr. Use for running tests, builds, git, and anything without a dedicated tool.”

When you’re ready to validate your approach or need help, here is the finished code:

var bashDef = ToolDef{
	Tool: Tool{
		Type: "function",
		Function: ToolFunction{
			Name: "bash",
			Description: "Execute a shell command and return its combined stdout and stderr. " +
				"Use for running tests, builds, git, and anything without a dedicated tool.",
			Parameters: json.RawMessage(`{
				"type": "object",
				"properties": {
					"command": {"type": "string", "description": "The shell command to run"}
				},
				"required": ["command"]
			}`),
		},
	},
	Run: bashTool,
}

func bashTool(args map[string]any) string {
	command, ok := args["command"].(string)
	if !ok {
		return "error: bash requires a string 'command' argument"
	}

	ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
	defer cancel()

	out, err := exec.CommandContext(ctx, "bash", "-c", command).CombinedOutput()
	if err != nil {
		return string(out) + "\nerror: " + err.Error()
	}
	if len(out) == 0 {
		return "(no output)"
	}
	return string(out)
}

Warning! Understand what you just built! You just gave your agent the ability to execute any command it wants. Including rm -rf. You may want to test drive this code in the repo’s folder and not in the root directory ;-)

In lesson 7, we’ll add permissions for tools in the dispatch layer to guard against any dangerous commands agent might want to execute.

Step 6: Test it

Let’s start by testing multi-tool reasoning:

❯ go run ./cmd/loom 
loom v0.3 — chatting with gemma4:e4b-mlx (ctrl-c to quit)

you: Look around this repo and tell me what this project actually is.
  ⚙ list_files(map[])
  ⚙ read_file(map[path:go.mod])
  ⚙ read_file(map[path:cmd/loom/main.go])

loom: This project appears to be a **local, conversational AI client** (named `loom`) designed to chat with a Large Language Model (specifically, one hosted via an Ollama service).

Here is a breakdown of what it does:

1.  **Chat Client:** It provides a command-line interface where you can interact in a back-and-forth conversation with an LLM (configured to use the `gemma4:e4b-mlx` model).
2.  **Tool/Function Calling:** This is the most significant feature. The project is not just a simple chatbot; it has integrated **tool-use** capabilities. It can allow the LLM to interact with the local environment to answer questions.
3.  **Available Tools:** It has defined functions that the AI can decide to run, such as:
    *   `read_file`: To read the contents of a specific file in the repository.
    *   `list_files`: To recursively list the files in a directory.
    *   `bash`: To execute arbitrary shell commands (like running tests, `git` commands, etc.).

**In short, it's a "code companion" or an "AI assistant CLI" that can not only chat but also execute commands and read the source code to answer complex queries about the project or the local system.**

And now let’s try some real work:

you: Run the build and tell me if it passes.
  ⚙ bash(map[command:go build ./cmd/loom])

loom: The build appears to have passed successfully, as the command produced no output (which typically signifies a clean build in this context).

This will compile loom and also leave the binary in the repo’s root so don’t forget to remove it.

What’s Next

In the next lesson we’ll look at a very interesting problem. How do you let a model change a file it can’t scroll through?

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.