LLM Tool Calling in Go: A ReAct-Style Agent Loop with Guardrails and Cost Control
Most of my agent projects in Go share one piece of code: the loop. The model looks at the conversation, decides to call a tool or answer, the program runs the tool, feeds the result back, and repeats. That's the ReAct pattern (reason, act, observe) in its practical form. I've built this loop severa

Most of my agent projects in Go share one piece of code: the loop. The model looks at the conversation, decides to call a tool or answer, the program runs the tool, feeds the result back, and repeats. That's the ReAct pattern (reason, act, observe) in its practical form.
I've built this loop several times: in agent-helper, a shared Go library that runs the same chat loop on Gemini or local Ollama models with an installable tool registry; in a Gemini tool-calling assistant for a Jira-style product; in a Clean Architecture ReAct agent that drafts social posts; and in a local voice assistant that uses Ollama tool calling. The happy path is about 40 lines. Most of the engineering is in the guardrails. This post builds a simplified version of that loop with the guardrails included.
Don't let the agent loop import a specific SDK. Define the minimum it needs:
type Role string const ( RoleUser Role = "user" RoleAssistant Role = "assistant" RoleTool Role = "tool" ) type ToolCall struct { ID string Name string Args json. RawMessage } type Message struct { Role Role Content string ToolCalls [] ToolCall // set when the model asks for tools ToolCallID string // set on tool results } type Usage struct { InputTokens, OutputTokens int } type ChatModel interface { Chat ( ctx context. Context, msgs [] Message, tools [] ToolSpec ) ( Message, Usage, error ) } Enter fullscreen mode Exit fullscreen mode Each provider (Gemini, OpenAI-compatible APIs, Ollama) gets a small adapter that converts to and from these types.
Switching between a hosted model and a local one then becomes a config change, which is exactly how agent-helper moves the same agent between Gemini and Ollama.
Tools are plug-ins. The loop shouldn't change when you add one.
type ToolSpec struct { Name string Description string Schema json. RawMessage // JSON Schema for the arguments } type Tool struct { Spec ToolSpec Mutates bool // changes external state? Timeout time.
Duration // per-call limit Run func ( ctx context. Context, args json. RawMessage ) ( string, error ) } type Registry struct { tools map [ string ] Tool } func ( r * Registry ) Register ( t Tool ) { if r. tools == nil { r. tools = map [ string ] Tool {} } r. tools [ t.
Spec. Name ] = t } func ( r * Registry ) Specs () [] ToolSpec { specs:= make ([] ToolSpec, 0, len ( r. tools )) for _, t:= range r. tools { specs = append ( specs, t. Spec ) } sort.
Slice ( specs, func ( i, j int ) bool { return specs [ i ]. Name < specs [ j ]. Name }) return specs } Enter fullscreen mode Exit fullscreen mode Sorting the specs keeps the prompt stable between calls, which helps reproducibility and, with providers that cache prompt prefixes, cost.
Inside Run, decode arguments strictly into a struct and validate them. The model's JSON is untrusted input:
func decodeArgs [ T any ]( raw json. RawMessage ) ( T, error ) { var v T dec:= json. NewDecoder ( bytes. NewReader ( raw )) dec. DisallowUnknownFields () if err:= dec. Decode ( & v ); err!= nil { return v, fmt. Errorf ( "invalid arguments: %w", err ) } return v, nil } Enter fullscreen mode Exit fullscreen mode Step 3: the loop, with budgets Here's the core. Every guardrail is explicit:
type Limits struct { MaxSteps int // model round-trips MaxTotalTokens int // input + output across the run MaxRepeats int // identical tool calls allowed RunTimeout time. Duration // wall clock for the whole run } type Approver func ( ctx context. Context, call ToolCall ) ( bool, error ) type Agent struct { Model ChatModel Tools * Registry Limits Limits Approve Approver // nil means write tools are refused } var ErrBudget = errors.
New ( "agent budget exhausted" ) func ( a * Agent ) Run ( ctx context. Context, msgs [] Message ) ( string, error ) { ctx, cancel:= context. WithTimeout ( ctx, a.
Limits. RunTimeout ) defer cancel () used:= 0 seen:= map [ string ] int {} for step:= 0; step < a. Limits.
MaxSteps; step ++ { reply, usage, err:= a. Model. Chat ( ctx, msgs, a.
Tools. Specs ()) if err!= nil { return "", fmt. Errorf ( "step %d: %w", step, err ) } used += usage.
InputTokens + usage. OutputTokens msgs = append ( msgs, reply ) if len ( reply. ToolCalls ) == 0 { return reply.
Content, nil // final answer } if used > a. Limits. MaxTotalTokens { return "", fmt.
Errorf ( "%w: %d tokens", ErrBudget, used ) } for _, call:= range reply. ToolCalls { key:= call. Name + string ( call.
Args ) seen [ key ] ++ var result string if seen [ key ] > a. Limits. MaxRepeats { result = "error: you already made this exact call; use the earlier result or try a different approach" } else { result = a. execute ( ctx, call ) } msgs = append ( msgs, Message { Role: RoleTool, ToolCallID: call.
ID, Content: result }) } } return "", fmt. Errorf ( "%w: max steps (%d)", ErrBudget, a. Limits.
MaxSteps ) } Enter fullscreen mode Exit fullscreen mode What each guardrail prevents:
func ( a * Agent ) execute ( ctx context. Context, call ToolCall ) string { tool, ok:= a. Tools. tools [ call. Name ] if! ok { return fmt. Sprintf ( "error: unknown tool %q", call. Name ) } if tool. Mutates { if a. Approve == nil { return "error: this tool changes data and is not allowed in this session" } ok, err:= a. Approve ( ctx, call ) if err!= nil ||! ok { return "error: the user did not approve this action" } } tctx, cancel:= context. WithTimeout ( ctx, tool. Timeout ) defer cancel () out, err:= tool. Run ( tctx, call. Args ) if err!= nil { return "error: " + err. Error () } const maxResult = 8_000 if len ( out ) > maxResult { out = out [: maxResult ] + " \n [truncated]" } return out } Enter fullscreen mode Exit fullscreen mode Three details matter here:
The loop's budgets are the safety net. These keep costs low in normal operation:
Because ChatModel is an interface, a scripted fake makes the loop fully testable without network calls:
type scriptedModel struct { replies [] Message; i int } func ( m * scriptedModel ) Chat ( ctx context. Context, _ [] Message, _ [] ToolSpec ) ( Message, Usage, error ) { r:= m. replies [ m. i ] m. i ++ return r, Usage { InputTokens: 100, OutputTokens: 20 }, nil } Enter fullscreen mode Exit fullscreen mode Write tests for the boring cases: unknown tool, invalid arguments, repeated call, budget exceeded, write tool without approval. Those are the paths that protect you in production.
The model decides what to try. Your Go code decides what is allowed, how long it may take and how much it may cost. Keep the loop small, keep the tools typed, and make every limit explicit.
Pradip Parmar is a Go backend and AI agent engineer who has worked in Go since 2018. He builds LLM agents with tool calling, MCP servers and RAG systems for businesses, from hosted Gemini and OpenAI-compatible models to fully local Ollama setups. To hire a Go AI agent developer, find him on LinkedIn, Upwork or Contra, and on GitHub.