In Chapter 1, the LLM could only talk. Here’s where it learns to act.
The Big Picture
In chapter 1, the LLM can only talk. In chapter 2, it can act — run commands, read files, edit files.
┌─────────────────────────────────────────────────┐
│ AGENT LOOP Chapter 2 │
│ │
│ User ──> messages[] ──> LLM API │
│ │ │
│ stop_reason? │
│ / \ │
│ end_turn tool_use │
│ │ │ │
│ Print TOOL_HANDLERS │
│ handler(input) │
│ │ │
│ tool_result │
│ │ │
│ messages[] ──> LLM │
│ (can chain again!) │
└─────────────────────────────────────────────────┘
New from Chapter 1: an inner loop inside the tool_use branch — because the LLM can call tools multiple times before finishing.
Three Key Concepts
1. Two Parallel Structures: TOOLS + TOOL_HANDLERS
// TOOLS → tell the LLM: "here's what you can use"
// (a schema is just a description of what inputs the tool accepts — name, types, required fields)
var tools = []map[string]any{
{
"name": "bash",
"description": "Run a shell command",
"input_schema": map[string]any{
"type": "object",
"properties": map[string]any{
"command": map[string]any{"type": "string"},
},
"required": []string{"command"},
},
},
// ... read_file, write_file, edit_file
}
// TOOL_HANDLERS → tell your code: "which function to call"
type ToolHandler func(input map[string]any) string
var toolHandlers = map[string]ToolHandler{
"bash": toolBash,
"read_file": toolReadFile,
"write_file": toolWriteFile,
"edit_file": toolEditFile,
}
Want to add a new tool? Just add one entry to TOOLS and one to TOOL_HANDLERS. The loop doesn’t change.
2. Dispatch — How the LLM “Calls” a Tool
The LLM doesn’t directly call a Python function. It returns a tool name + input as JSON. Your code executes it.
func processToolCall(toolName string, toolInput map[string]any) string {
handler, ok := toolHandlers[toolName]
if !ok {
return "error: unknown tool " + toolName
}
return handler(toolInput)
}
Flow:
LLM says: "call bash with command='ls -la'"
↓
process_tool_call("bash", {"command": "ls -la"})
↓
tool_bash(command="ls -la")
↓
result returned to LLM as a string
Errors are returned as strings rather than panics — the LLM can read the error text and decide to try a different approach. If you panicked instead, the LLM would never see what went wrong.
When stop_reason is tool_use, the response looks like this:
// response.Content[0].Name → "bash"
// response.Content[0].Input → {"command": "ls -la"}
3. Inner Loop — The LLM Can Chain Tools
User: "add a comment to hello.txt"
↓
LLM → tool_use: read_file("hello.txt") ← read first
↓
LLM → tool_use: edit_file(...) ← then edit
↓
LLM → end_turn: "Done"
You collect all tool results into a slice, then send them back as a single user message.
for {
response, _ := client.Messages.Create(ctx, params)
if response.StopReason == "end_turn" {
break
}
if response.StopReason == "tool_use" {
// execute tools, add results to messages as "user"
messages = append(messages, anthropic.NewUserMessage(toolResults...))
continue // back to LLM
}
}
Important: Tool results go into messages[] with role: "user" — this is an Anthropic API requirement.
Takeaway
- Tools — TOOLS (schema for the LLM) + TOOL_HANDLERS (functions for your code)
- Add a new tool — add 2 entries, no loop changes
- The LLM doesn’t “run” anything — it returns a name + input, you execute
- Tool results must go into
messages[]with role “user” - Inner loop — LLM can chain multiple tools before
end_turn