An agent is a tool-calling loop that runs until the job is done. The happy path fits in twenty lines. Production needs a few more.
Estimated Reading Time : 9m
From loop to agent
The loop from Tool Calling from First Principles already is an agent, technically. It calls the model, runs whatever tools the model asks for, and repeats until the model stops asking.
What it lacks is everything that goes wrong in practice:
- The model asks for three tools at once
- A tool hangs
- The model never stops calling tools
- The response gets cut off at
max_tokens - The model declines the request
Let’s build a small agent type that handles each of these.
The shape
type Tool struct {
Param anthropic.ToolParam
Run func(ctx context.Context, input json.RawMessage) (string, error)
}
type Agent struct {
Client anthropic.Client
System string
Tools []Tool
MaxTurns int
}
Tools is a slice, not a map, on purpose. The tool list is sent with every request, and Go map iteration order is random. A different order means different request bytes, which quietly breaks prompt caching. More on that in a later post.
The loop
func (a *Agent) Run(ctx context.Context, prompt string) (string, error) {
byName := make(map[string]Tool, len(a.Tools))
params := anthropic.MessageNewParams{
Model: "claude-opus-5-5",
MaxTokens: 16000,
System: []anthropic.TextBlockParam{{Text: a.System}},
Messages: []anthropic.MessageParam{
anthropic.NewUserMessage(anthropic.NewTextBlock(prompt)),
},
}
for _, t := range a.Tools {
byName[t.Param.Name] = t
params.Tools = append(params.Tools, anthropic.ToolUnionParam{OfTool: &t.Param})
}
for turn := 0; turn < a.MaxTurns; turn++ {
resp, err := a.Client.Messages.New(ctx, params)
if err != nil {
return "", fmt.Errorf("turn %d: %w", turn, err)
}
params.Messages = append(params.Messages, resp.ToParam())
switch resp.StopReason {
case anthropic.StopReasonEndTurn:
return finalText(resp), nil
case anthropic.StopReasonToolUse:
var calls []anthropic.ToolUseBlock
for _, block := range resp.Content {
if call, ok := block.AsAny().(anthropic.ToolUseBlock); ok {
calls = append(calls, call)
}
}
results := runTools(ctx, byName, calls)
params.Messages = append(params.Messages, anthropic.NewUserMessage(results...))
case anthropic.StopReasonMaxTokens:
return "", errors.New("response was cut off at max_tokens")
case anthropic.StopReasonRefusal:
return "", fmt.Errorf("model declined the request: %s", resp.StopDetails.Explanation)
default:
return "", fmt.Errorf("unexpected stop reason %q", resp.StopReason)
}
}
return "", fmt.Errorf("no answer after %d turns", a.MaxTurns)
}
func finalText(resp *anthropic.Message) string {
var sb strings.Builder
for _, block := range resp.Content {
if text, ok := block.AsAny().(anthropic.TextBlock); ok {
sb.WriteString(text.Text)
}
}
return sb.String()
}
Note that t in the range loop is a fresh variable per iteration since Go 1.22, so &t.Param points at a different tool each time.
Handle every stop reason
The switch is the heart of the agent. Treat it as exhaustive:
end_turn: the model is done. Return its text.tool_use: run the requested tools and loop.max_tokens: the response was truncated mid-thought. Don’t loop on a truncated turn. Either raiseMaxTokens(and switch to streaming for large values, to avoid HTTP timeouts) or fail clearly.refusal: the model declined.resp.StopDetailssays why. Looping won’t change the answer.pause_turn: only appears if you use server-side tools like web search. Re-send the conversation as is and the server picks up where it left off.
The default case matters more than it looks. When a new stop reason appears, you want a clear error, not an infinite loop.
Bound the loop
MaxTurns is your circuit breaker. A model that keeps calling a failing tool, or bouncing between two tools, will happily do so until your budget runs out. Pick a limit that’s generous for real tasks, like 20 or 30, and treat hitting it as a bug worth investigating.
Combine it with a deadline on the context:
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
answer, err := agent.Run(ctx, "Why is the worker deployment failing?")
Every API call and every tool gets the same ctx, so one timeout bounds the whole run.
Run tools in parallel
When the model asks for several tools in one turn, it’s telling you they’re independent. Run them concurrently, but return the results in one user message:
func runTools(ctx context.Context, byName map[string]Tool, calls []anthropic.ToolUseBlock) []anthropic.ContentBlockParamUnion {
results := make([]anthropic.ContentBlockParamUnion, len(calls))
var wg sync.WaitGroup
for i, call := range calls {
wg.Add(1)
go func() {
defer wg.Done()
results[i] = runTool(ctx, byName, call)
}()
}
wg.Wait()
return results
}
func runTool(ctx context.Context, byName map[string]Tool, call anthropic.ToolUseBlock) anthropic.ContentBlockParamUnion {
t, ok := byName[call.Name]
if !ok {
return anthropic.NewToolResultBlock(call.ID, "unknown tool "+call.Name, true)
}
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
out, err := t.Run(ctx, json.RawMessage(call.JSON.Input.Raw()))
if err != nil {
return anthropic.NewToolResultBlock(call.ID, err.Error(), true)
}
return anthropic.NewToolResultBlock(call.ID, out, false)
}
Three details:
- Each goroutine writes its own index, so there’s no shared state to lock. This is the same pattern from Go Concurrency.
- Every call gets a result, even unknown tools and failures. Leaving one out makes the next request invalid.
- One message for all results. Splitting parallel results across several user messages works, but it nudges the model away from making parallel calls in the future.
Append, never rewrite
resp.ToParam() keeps the model’s whole turn, including any thinking blocks. Claude Opus 5.5 always thinks, and newer models check that earlier turns weren’t edited before trusting the thinking in them. Treat the message history as append-only. If you need to inject instructions mid-run, add a new message rather than editing an old one.
Retries and API errors
The SDK already retries rate limits, overloaded errors and connection failures twice by default. For anything that’s left, distinguish retryable from fatal:
var apiErr *anthropic.Error
if errors.As(err, &apiErr) {
switch {
case apiErr.StatusCode == 429 || apiErr.StatusCode >= 500:
// back off and retry the same request
default:
// 400s mean the request itself is wrong; retrying won't help
}
}
Retrying is safe here because the request doesn’t change, and nothing has executed yet. Tool execution is where you have to think about idempotency.
Or let the SDK run the loop
If you don’t need this much control, the Go SDK ships a beta tool runner that does the loop for you. Tools are defined from Go structs, and RunToCompletion returns the final message:
runner := client.Beta.Messages.NewToolRunner(tools, anthropic.BetaToolRunnerParams{
BetaMessageNewParams: anthropic.BetaMessageNewParams{
Model: "claude-opus-5-5",
MaxTokens: 16000,
Messages: []anthropic.BetaMessageParam{
anthropic.NewBetaUserMessage(anthropic.NewBetaTextBlock(prompt)),
},
},
MaxIterations: 20,
})
msg, err := runner.RunToCompletion(ctx)
I still think writing the loop once by hand is worth it. When something goes wrong in the runner, you’ll know exactly where to look.
Wrapping up
The difference between a demo agent and a production one is the edges: an exhaustive switch on stop reasons, a turn limit and a deadline, parallel tools with a result for every call, append-only history, and a clear line between retryable and fatal errors. None of it is complicated, and all of it is the kind of thing you only add after the 2am page.