“Agent” is doing a lot of work as a word right now. Strip the marketing off and what is underneath is a for loop: you send a message, the model asks you to run something, you run it, you send the result back, repeat until it stops asking. That is genuinely all it is — and once you have written the loop yourself, most of the mystique evaporates and what is left is a set of very ordinary Go problems.
The Loop, In Full
Here is a complete manual loop. It is worth reading once even if you end up using the SDK’s runner, because everything that goes wrong later is easier to diagnose when you know this shape. It assumes you already have a client and know how to read a response — if not, start with calling an LLM from Go.
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"github.com/anthropics/anthropic-sdk-go"
)
func main() {
client := anthropic.NewClient()
addTool := anthropic.ToolParam{
Name: "add",
Description: anthropic.String("Add two integers"),
InputSchema: anthropic.ToolInputSchemaParam{
Properties: map[string]any{
"a": map[string]any{"type": "integer"},
"b": map[string]any{"type": "integer"},
},
},
}
tools := []anthropic.ToolUnionParam{{OfTool: &addTool}}
messages := []anthropic.MessageParam{
anthropic.NewUserMessage(anthropic.NewTextBlock("What is 2 + 3?")),
}
for {
resp, err := client.Messages.New(context.Background(), anthropic.MessageNewParams{
Model: "claude-opus-5",
MaxTokens: 16000,
Messages: messages,
Tools: tools,
})
if err != nil {
log.Fatal(err)
}
// Append the assistant turn BEFORE handling the tool calls.
messages = append(messages, resp.ToParam())
var toolResults []anthropic.ContentBlockParamUnion
for _, block := range resp.Content {
switch variant := block.AsAny().(type) {
case anthropic.TextBlock:
fmt.Println(variant.Text)
case anthropic.ToolUseBlock:
var in struct {
A int `json:"a"`
B int `json:"b"`
}
// block.Input is raw JSON — parse it, never string-match it.
if err := json.Unmarshal([]byte(variant.JSON.Input.Raw()), &in); err != nil {
log.Fatal(err)
}
result := fmt.Sprintf("%d", in.A+in.B)
toolResults = append(toolResults,
anthropic.NewToolResultBlock(block.ID, result, false))
}
}
if resp.StopReason != anthropic.StopReasonToolUse {
break
}
// All results from this turn go back in ONE user message.
messages = append(messages, anthropic.NewUserMessage(toolResults...))
}
}
Five things in there are load-bearing, and four of them are easy to get subtly wrong.
resp.ToParam() converts the response into a history entry. You must append the assistant’s turn — including its tool-call blocks — before you send the results, or the next request has results referring to a call that does not exist in the conversation.
Parse the tool input; never pattern-match the raw string. variant.JSON.Input.Raw() gives you the JSON to unmarshal. Current models vary their JSON string escaping (Unicode escapes, escaped forward slashes), so anything doing strings.Contains on the serialised input is a bug waiting for a release.
All tool results go back in a single user message. anthropic.NewUserMessage is variadic for exactly this reason. Splitting results across several messages technically works, and it quietly teaches the model to stop issuing parallel calls — which halves your throughput for no visible reason.
StopReason is the exit condition, not “did I see any tool blocks”. Check it after you have appended the results, not before.
Every tool call needs a result. If the model asked for three tools and you return two results, the next request is malformed. Including for the one that failed — which brings us to the most useful trick in this whole article.
Errors Are Results, Not Exceptions
The instinct when a tool fails is to abort the loop. Usually that is wrong. Hand the failure back to the model as a tool result flagged as an error, and it will very often recover on its own — retry with a corrected argument, try a different tool, or tell the user what went wrong:
out, err := h.run(ctx, variant)
if err != nil {
// isError = true. The model sees the failure and can adapt.
toolResults = append(toolResults,
anthropic.NewToolResultBlock(block.ID, err.Error(), true))
continue
}
toolResults = append(toolResults, anthropic.NewToolResultBlock(block.ID, out, false))
That third parameter is isError. Getting this right turns a class of hard failures into self-correcting ones.
One caveat worth stating plainly: the error text goes into the model’s context, so do not put a raw database error with connection strings and internal hostnames in there. Return the error you would show a careful external user. This is the same discipline as deciding what a sentinel error exposes at your HTTP boundary, which I covered in error handling in Go.
Let the SDK Drive
Once you understand the loop, you mostly do not want to maintain it. The Go SDK’s tool runner handles the iteration, and generates the JSON schema from your struct tags:
import "github.com/anthropics/anthropic-sdk-go/toolrunner"
type GetWeatherInput struct {
City string `json:"city" jsonschema:"required,description=The city name"`
}
weatherTool, err := toolrunner.NewBetaToolFromJSONSchema(
"get_weather",
"Get current weather for a city",
func(ctx context.Context, in GetWeatherInput) (anthropic.BetaToolResultBlockParamContentUnion, error) {
return anthropic.BetaToolResultBlockParamContentUnion{
OfText: &anthropic.BetaTextBlockParam{
Text: fmt.Sprintf("The weather in %s is sunny, 22°C", in.City),
},
}, nil
},
)
if err != nil {
return err
}
runner := client.Beta.Messages.NewToolRunner(
[]anthropic.BetaTool{weatherTool},
anthropic.BetaToolRunnerParams{
BetaMessageNewParams: anthropic.BetaMessageNewParams{
Model: "claude-opus-5",
MaxTokens: 16000,
Messages: []anthropic.BetaMessageParam{
anthropic.NewBetaUserMessage(anthropic.NewBetaTextBlock("What's the weather in Kyiv?")),
},
},
MaxIterations: 5,
},
)
message, err := runner.RunToCompletion(ctx)
Note the namespace: this lives under client.Beta.Messages, and the types are the Beta* variants — BetaTextBlock, not TextBlock. Mixing the two is the most common compile error here.
MaxIterations is not optional decoration. Without a ceiling, a model that gets into a retry rut can loop until your context deadline, and you pay for every turn. Set it to the smallest number that lets legitimate work finish.
If you need to inspect or gate each step — approvals, audit logging, a check before a destructive tool runs — you do not have to drop back to a manual loop. The runner exposes NextMessage() and an All() iterator so you can step it and look at each message, and its Params field lets you adjust the next request. Reach for the manual loop only when you want control the runner genuinely does not expose.
Running Tools Concurrently — With a Limit
When the model asks for four tools in one turn, running them sequentially wastes the whole point. But the naive concurrent version is the same mistake Go developers make everywhere else:
// Don't. One turn can ask for many tools; this has no ceiling.
for _, call := range calls {
go run(call)
}
Use a bounded group. The results still have to come back in one message, in a fixed order, so index into a preallocated slice rather than appending from goroutines:
import "golang.org/x/sync/errgroup"
results := make([]anthropic.ContentBlockParamUnion, len(calls))
g, gctx := errgroup.WithContext(ctx)
g.SetLimit(4) // whatever your slowest downstream can absorb
for i, call := range calls {
g.Go(func() error {
out, err := h.run(gctx, call)
if err != nil {
// Not a group error: hand it to the model instead.
results[i] = anthropic.NewToolResultBlock(call.ID, err.Error(), true)
return nil
}
results[i] = anthropic.NewToolResultBlock(call.ID, out, false)
return nil
})
}
if err := g.Wait(); err != nil {
return err
}
messages = append(messages, anthropic.NewUserMessage(results...))
Each goroutine writes one distinct slice element, so no mutex is needed — different elements are different memory. Appending to a shared slice from several goroutines is a different story, and so is writing to a shared map; concurrent map writing and reading in Go has the failure mode in detail.
Notice that a tool failure returns nil from g.Go. Returning the error would cancel gctx and kill the sibling tool calls, when what you actually want is to report that one failure to the model and let the others finish. The full set of tradeoffs around SetLimit and error propagation is in worker pools in Go with errgroup.
Designing the Tools Themselves
The loop is the easy part. Tool design is where agents get good or stay bad.
The description is the API documentation, and its reader is the model. A tool called search described as “searches” will be called wrongly and often. Say what it searches, what it returns, and when not to use it. Most “the agent keeps doing the wrong thing” problems are description problems.
Fewer, broader tools beat many narrow ones. Twenty tools that each wrap one endpoint force the model to plan a long chain and give it twenty chances to pick wrong. One query_orders tool with a few well-named parameters usually outperforms get_order, list_orders_by_user, list_orders_by_date and count_orders.
Constrain the schema. Enums, required fields and explicit types are enforced before your handler runs. Every constraint you express in the schema is a class of invalid call you never have to validate by hand.
Make results terse. Tool results occupy context on every subsequent turn of the loop. Returning a 400-row JSON dump costs you tokens on turn two, turn three and turn four. Return the fields the model needs to decide what to do next, and nothing else.
Be deliberate about side effects. The model will call your tools in orders you did not anticipate. Anything that writes, sends, charges or deletes wants an approval gate — step the runner and confirm — or, at minimum, idempotency so a double call is harmless.
Guardrails That Actually Matter in Production
A tool loop has a cost profile unlike a normal handler: every iteration resends the whole conversation. The bill grows quadratically with the number of turns if you are not careful, and three things keep it honest.
Cap the iterations. MaxIterations on the runner, or a counter in your manual loop. Non-negotiable.
Bound the wall clock. A deadline on the context that covers the whole loop, not each request:
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
Cache the prefix. Every turn resends the system prompt and the full history. Without prompt caching you pay full price for all of it, every iteration — this is where agent loops get expensive, and it is the one lever with no quality tradeoff at all. It gets its own article.
Then there is the interaction between concurrency and rate limits. A pool of four tool calls, times however many concurrent user requests, times however many turns each — an agent loop is a very effective way to discover your own rate limits. The token-bucket approach from rate limiting Go APIs works just as well pointed at your own outbound calls as at inbound traffic.
Observability, Or You Are Flying Blind
When a loop misbehaves, you need to see what the model actually saw. Log per iteration:
slog.Info("agent turn",
"iteration", i,
"stop_reason", resp.StopReason,
"tools_called", toolNames,
"input_tokens", resp.Usage.InputTokens,
"output_tokens", resp.Usage.OutputTokens,
"cache_read", resp.Usage.CacheReadInputTokens,
)
With a request-scoped logger carrying the conversation id (the pattern from the slog post), you can pull the entire trajectory of one run out of your logs — which is the difference between “the agent is flaky” and “on turn three it called search with an empty query because the description was ambiguous”.
Pitfalls
| Pitfall | Fix |
|---|---|
| Results returned for only some tool calls | Return one result per tool_use block, failures included |
| Tool results split across several user messages | One user message, all results, variadic NewUserMessage |
| Assistant turn not appended before results | messages = append(messages, resp.ToParam()) first |
| String-matching the raw tool input | json.Unmarshal(variant.JSON.Input.Raw()) |
Mixing TextBlock and BetaTextBlock |
Pick a namespace; the runner is Beta.* throughout |
| Loop runs until the deadline | MaxIterations, plus a context timeout for the whole loop |
| Tool error cancels its siblings | Return nil from g.Go; hand the error to the model |
| Cost grows faster than expected | Cache the prefix; keep tool results terse |
Conclusion
The loop is twenty lines and you should write it once by hand, then let the runner own it. After that, the work that actually improves an agent is not loop code at all: sharper tool descriptions, tighter schemas, terser results, a hard iteration cap, and enough logging to reconstruct a bad run. The interesting engineering is in the tools, not the loop around them.