Building an Agent Loop in Go

July 21, 2026

Building an Agent Loop in Go
Building an Agent Loop in Go

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 raise MaxTokens (and switch to streaming for large values, to avoid HTTP timeouts) or fail clearly.
  • refusal: the model declined. resp.StopDetails says 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.