Skip to content

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:

  1. The model reasons about the prompt and may decide to call a tool.
  2. If it does, the client invokes your Handler, captures the result, and injects it back into the conversation.
  3. Steps 1–2 repeat until the model returns a final response, or Config.MaxSteps is 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)