Tool Calling from First Principles in Go

April 14, 2026

Tool Calling from First Principles in Go
Tool Calling from First Principles in Go

A language model can only produce text. Tool calling is the protocol that turns that text into actions your code performs on its behalf.

Estimated Reading Time : 7m

The problem

Ask a model “is the worker pod in the default namespace healthy?” and the best it can do is guess. It has no access to your cluster. It doesn’t know what time it is. Everything it knows was frozen at training time.

Tool calling fixes this without giving the model any real access. The model never runs anything. It asks you to run something, you decide whether to, and you hand back the result.

How it works

There are four steps, and they repeat until the model has what it needs:

1. You:    "Is worker-5c2a healthy?"  +  tool definitions
2. Model:  tool_use { id: "toolu_01", name: "get_pod_status",
                      input: { namespace: "default", name: "worker-5c2a" } }
3. You:    run get_pod_status(...) and reply with
           tool_result { tool_use_id: "toolu_01", content: "CrashLoopBackOff" }
4. Model:  "No. worker-5c2a is in CrashLoopBackOff..."

A tool definition has three parts:

  • Name: what the model calls it
  • Description: plain English that tells the model what the tool does and when to use it
  • Input schema: a JSON Schema describing the arguments

The model reads all three on every request. The description does more work than you’d expect. It’s the only signal the model has for deciding whether a tool is relevant.

Setting up

Install the Anthropic Go SDK and export an API key:

go get github.com/anthropics/anthropic-sdk-go/v2
export ANTHROPIC_API_KEY=sk-ant-...

anthropic.NewClient() picks up the key from the environment.

A complete example

Here’s a program that gives Claude one tool, a fake pod status lookup, and runs the loop until Claude answers:

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "log"

    "github.com/anthropics/anthropic-sdk-go/v2"
)

var pods = map[string]string{
    "default/api-7d9f":    "Running",
    "default/worker-5c2a": "CrashLoopBackOff",
}

func getPodStatus(namespace, name string) (string, error) {
    status, ok := pods[namespace+"/"+name]
    if !ok {
        return "", fmt.Errorf("pod %s/%s not found", namespace, name)
    }
    return status, nil
}

func main() {
    client := anthropic.NewClient()
    ctx := context.Background()

    podTool := anthropic.ToolParam{
        Name: "get_pod_status",
        Description: anthropic.String(
            "Get the current phase of a Kubernetes pod. " +
                "Call this whenever the user asks whether a pod is running or healthy."),
        InputSchema: anthropic.ToolInputSchemaParam{
            Properties: map[string]any{
                "namespace": map[string]any{"type": "string", "description": "Namespace the pod runs in"},
                "name":      map[string]any{"type": "string", "description": "Full pod name"},
            },
            Required: []string{"namespace", "name"},
        },
    }
    tools := []anthropic.ToolUnionParam{{OfTool: &podTool}}

    messages := []anthropic.MessageParam{
        anthropic.NewUserMessage(anthropic.NewTextBlock(
            "Is worker-5c2a in the default namespace healthy?")),
    }

    for {
        resp, err := client.Messages.New(ctx, anthropic.MessageNewParams{
            Model:     "claude-opus-5-5",
            MaxTokens: 16000,
            Messages:  messages,
            Tools:     tools,
        })
        if err != nil {
            log.Fatal(err)
        }

        // Keep the model's turn in the history, tool_use blocks included.
        messages = append(messages, resp.ToParam())

        var results []anthropic.ContentBlockParamUnion
        for _, block := range resp.Content {
            switch b := block.AsAny().(type) {
            case anthropic.TextBlock:
                fmt.Println(b.Text)
            case anthropic.ToolUseBlock:
                var in struct {
                    Namespace string `json:"namespace"`
                    Name      string `json:"name"`
                }
                if err := json.Unmarshal([]byte(b.JSON.Input.Raw()), &in); err != nil {
                    results = append(results, anthropic.NewToolResultBlock(b.ID, err.Error(), true))
                    continue
                }
                status, err := getPodStatus(in.Namespace, in.Name)
                if err != nil {
                    results = append(results, anthropic.NewToolResultBlock(b.ID, err.Error(), true))
                    continue
                }
                results = append(results, anthropic.NewToolResultBlock(b.ID, status, false))
            }
        }

        if resp.StopReason != anthropic.StopReasonToolUse {
            break
        }
        messages = append(messages, anthropic.NewUserMessage(results...))
    }
}

Run it and Claude calls get_pod_status, gets CrashLoopBackOff, and explains that the pod is not healthy.

Walking through the loop

resp.StopReason tells you why the model stopped generating. tool_use means “I asked for a tool and I’m waiting for the result.” Anything else, usually end_turn, means the model is done.

resp.ToParam() converts the response into a message you can append to the history. Append the whole thing, not just the text. The tool_use blocks have to be in the history, because every tool_result you send back points at one by ID.

tool_use_id is how the model matches results to calls. anthropic.NewToolResultBlock(id, content, isError) takes the ID from the tool_use block. Get it wrong and the API rejects the request.

Tool results go in a user message. From the model’s point of view, the results come from the other side of the conversation. If the model asks for several tools at once, send every result back in a single user message.

Parse the input, don’t pattern-match it. b.JSON.Input.Raw() is the raw JSON the model produced. Always decode it with encoding/json. The exact escaping can vary between models, so string matching on the raw input breaks.

Errors are results too

Notice that a missing pod doesn’t crash the program. It goes back to the model as a tool result with isError set to true:

results = append(results, anthropic.NewToolResultBlock(b.ID, "pod default/worker-9 not found", true))

The model reads the error like any other result. It might retry with a corrected name, ask the user which pod they meant, or report that the pod doesn’t exist. If you log.Fatal instead, you’ve thrown away the model’s chance to recover.

A good error message is one the model can act on. "not found" is OK. "pod worker-9 not found; pods in default: api-7d9f, worker-5c2a" is better.

The model never touches your system

It’s worth repeating, because it shapes everything that comes later: the model only ever asks. Your code decides what actually runs. That means:

  • You validate every input before using it
  • You decide which tools exist in the first place
  • You can refuse a call and tell the model why

That boundary is what makes tool calling safe to build on, and it’s the same boundary that MCP servers and agent frameworks are built around.

Wrapping up

Tool calling is a loop: describe tools, let the model ask for one, run it, return the result, repeat until stop_reason isn’t tool_use. Keep the full assistant turn in the history, match results by ID, and treat errors as results the model can reason about. Everything else in this space, from agents to MCP, builds on these four steps.