Call tools (the ReAct loop)¶
Tool calling lets the model decide, mid-conversation, which of your Go functions to invoke. The chat client runs the ReAct (Reason → Act → Observe) loop for you: you define the tools, and the client manages the back-and-forth until the model produces a final text answer.
Tool calling is supported by claude, openai, openai-compatible, and
gemini. claude-local does not support it (SetTools returns an error).
How the loop works¶
When you call client.Chat(ctx, prompt) with tools registered:
- The model reasons about the prompt and may decide to call a tool.
- If it does, the client invokes your
Handler, captures the result, and injects it back into the conversation. - Steps 1–2 repeat until the model returns a final response, or
Config.MaxStepsis reached (default 20).
Chat returns the final text. A tool error (handler error, or tool-not-found)
is fed back to the model as an error string — the loop continues rather than
aborting.
Step 1 — Define your tools¶
Each chat.Tool has a name, description, a JSON Schema for its parameters, and a
handler. Generate the schema from an exported, tagged parameter struct with
chat.GenerateSchema:
import (
"context"
"encoding/json"
"os"
"github.com/invopop/jsonschema"
"gitlab.com/phpboyscout/go/chat"
)
type ReadFileParams struct {
Path string `json:"path" jsonschema_description:"Relative path to the file"`
}
var readFileTool = chat.Tool{
Name: "read_file",
Description: "Read the contents of a file at the given path",
Parameters: chat.GenerateSchema[ReadFileParams]().(*jsonschema.Schema),
Handler: func(ctx context.Context, args json.RawMessage) (any, error) {
var p ReadFileParams
if err := json.Unmarshal(args, &p); err != nil {
return nil, err
}
content, err := os.ReadFile(p.Path)
if err != nil {
return nil, err
}
return string(content), nil
},
}
A handler returns (any, error). A string result is passed back verbatim;
any other value is JSON-marshalled first.
Step 2 — Register the tools and chat¶
SetTools replaces the client's entire tool set on every call — it never
merges — so a stale handler can never linger. For Claude it also clears any
ResponseSchema set at construction, since structured-output Ask and tool
calling are mutually exclusive.
client, err := chat.New(ctx, chat.Settings{Config: chat.Config{
Provider: chat.ProviderClaude,
SystemPrompt: "Use the provided tools to explore before answering.",
MaxSteps: 15, // cap ReAct iterations
}})
if err != nil {
return err
}
if err := client.SetTools([]chat.Tool{readFileTool}); err != nil {
return err
}
answer, err := client.Chat(ctx, "Read go.mod and tell me the module path.")
Handlers are plain closures, so capture any dependency (a config, a DB handle,
an afero.Fs) from the enclosing scope.
Step 3 — Observe execution¶
Every tool call is logged through the injected *slog.Logger: the call at
INFO, the parameters at DEBUG, and the outcome at INFO/WARN. No extra
wiring is required.
INFO Tool Call tool=read_file
DEBUG Tool Parameters tool=read_file args={"path":"go.mod"}
INFO Tool executed successfully tool=read_file
Parallel tool execution¶
When a provider returns more than one tool call in a single step, they can
run concurrently instead of sequentially — cutting latency for I/O-bound tools.
Opt in with ParallelTools:
cfg := chat.Config{
Provider: chat.ProviderClaude,
ParallelTools: true,
MaxParallelTools: 3, // optional; defaults to 5
}
Behaviour:
- Off by default — sequential execution unless opted in.
- Activates only when a single step returns more than one call; a lone call always takes the sequential path.
- Results are returned in the same order as the input calls, regardless of completion order.
- Context cancellation propagates to all in-flight tool goroutines.
- Tool errors become error strings in the conversation (same as sequential) — they never abort the loop.
- Bounded by
MaxParallelTools(default 5) to prevent goroutine storms.
Thread safety: each handler receives an independent json.RawMessage and
returns an independent result. Parallel execution is safe as long as your
handlers do not share mutable state without synchronisation.
Panics become tool-error content¶
Tool handlers run model-generated, potentially adversarial input. A handler that
panics is recovered and converted to a tool-error string
(Error: tool handler panicked: <value>) fed back to the model as conversation
content — exactly like a returned error — rather than crashing the process.
This holds on both the sequential and parallel paths (in the parallel path a
handler runs in a bare goroutine, where an unrecovered panic would otherwise be
fatal). The recovered value is also logged at Error level. Treat the recover
as a safety net, not a substitute for returning errors explicitly.
Combining tools with structured output¶
Add, Chat, and Ask share one conversation history on a client instance, so
you can let the model explore with tools, then ask for a typed summary:
// Explore with tools enabled
_, err = client.Chat(ctx, "Examine the database package and list what you find.")
// Ask for a structured summary of the same conversation
var report DatabaseReport
err = client.Ask(ctx, "Produce a structured summary of your exploration.", &report)
Related¶
- Stream responses — tool calls are surfaced as stream events too.
- Architecture & the seams — the loop in depth.
- pkg.go.dev reference — the
Tooltype.