A complete guide to defining, attaching, and executing tools with Turns. Tool registries are carried via `context.Context` (Turn state stays serializable).
Large language models can generate text, but they can't access databases, call APIs, or perform calculations directly. Tools bridge this gap by letting models request specific function calls with structured inputs.
When a model needs information it doesn't have (like today's weather) or wants to perform an action (like sending an email), it emits a tool call with the function name and arguments. Your code executes the function and returns the result, allowing the model to continue with fresh information.
Example flow:
User: "What's the weather in Paris?"
↓
Model: tool_call {name: "get_weather", args: {location: "Paris"}}
↓
Your code: executes get_weather("Paris") → {temp: 18, conditions: "Cloudy"}
↓
Model: "The weather in Paris is 18°C and cloudy."
In the Turn-based architecture:
tool_call blocks when models request toolstool_use blocksKey Pattern: The runtime
tools.ToolRegistryis carried viacontext.Context(seetools.WithRegistry). Only serializable tool configuration lives onTurn.Data(e.g.,engine.KeyToolConfig). This keeps Turn state persistable while allowing dynamic tools per inference call.
import (
"github.com/go-go-golems/geppetto/pkg/inference/engine"
"github.com/go-go-golems/geppetto/pkg/inference/session"
"github.com/go-go-golems/geppetto/pkg/inference/toolloop"
"github.com/go-go-golems/geppetto/pkg/inference/toolloop/enginebuilder"
"github.com/go-go-golems/geppetto/pkg/inference/tools"
"github.com/go-go-golems/geppetto/pkg/turns"
)
Turn for provider advertisementtools.ToolRegistry holds callable toolscontext.Context using tools.WithRegistry(ctx, reg)Turn.Data via engine.KeyToolConfigllm_text, tool_call, tool_useWhen using the OpenAI Responses engine (ai-api-type=openai-responses):
tools array on the request. For function tools, schema is top-level: {type: "function", name, description, parameters}.tool-call event when the function_call completes.partial-thinking / EventThinkingPartial events. UIs can render it between "Thinking started/ended" markers and should use the event's Completion field for accumulated thinking text.assistant:function_call and tool:tool_result blocks in the next request’s input to continue tool-driven workflows.turns.PayloadKeyText, turns.PayloadKeyID, turns.PayloadKeyName, turns.PayloadKeyArgs, turns.PayloadKeyResult, turns.PayloadKeyErrortype WeatherRequest struct {
Location string `json:"location" jsonschema:"required"`
Units string `json:"units,omitempty" jsonschema:"enum=celsius,enum=fahrenheit,default=celsius"`
}
type WeatherResponse struct {
Location string
Temperature float64
}
func weatherTool(req WeatherRequest) WeatherResponse {
return WeatherResponse{Location: req.Location, Temperature: 22}
}
reg := tools.NewInMemoryToolRegistry()
def, _ := tools.NewToolFromFunc("get_weather", "Get weather", weatherTool)
_ = reg.RegisterTool("get_weather", *def)
seed := &turns.Turn{}
turns.AppendBlock(seed, turns.NewUserTextBlock("What's the weather in Paris? Use get_weather."))
loopCfg := toolloop.NewLoopConfig().
WithMaxIterations(5)
toolCfg := tools.DefaultToolConfig().
WithMaxParallelTools(1).
WithToolChoice(tools.ToolChoiceAuto).
WithToolErrorHandling(tools.ToolErrorContinue)
loop := toolloop.New(
toolloop.WithEngine(e),
toolloop.WithRegistry(reg),
toolloop.WithLoopConfig(loopCfg),
toolloop.WithToolConfig(toolCfg),
)
updated, err := loop.RunLoop(ctx, seed)
runner, _ := enginebuilder.New(
enginebuilder.WithBase(e),
enginebuilder.WithToolRegistry(reg),
enginebuilder.WithLoopConfig(loopCfg),
enginebuilder.WithToolConfig(toolCfg),
).Build(ctx, "demo-session")
updated, _ := runner.RunInference(ctx, seed)
This is the minimal “wire it up and run” pattern. It assumes you already have an engine.Engine (via factory.NewEngineFromParsedValues(...) or your own builder) and a populated tools.ToolRegistry:
import (
"context"
"time"
"github.com/go-go-golems/geppetto/pkg/events"
"github.com/go-go-golems/geppetto/pkg/inference/engine"
"github.com/go-go-golems/geppetto/pkg/inference/toolloop"
"github.com/go-go-golems/geppetto/pkg/inference/tools"
"github.com/go-go-golems/geppetto/pkg/turns"
)
func RunWithTools(ctx context.Context, eng engine.Engine, reg tools.ToolRegistry, seed *turns.Turn, sinks ...events.EventSink) (*turns.Turn, error) {
if len(sinks) > 0 {
ctx = events.WithEventSinks(ctx, sinks...)
}
loopCfg := toolloop.NewLoopConfig().WithMaxIterations(5)
toolCfg := tools.DefaultToolConfig().WithExecutionTimeout(60 * time.Second)
loop := toolloop.New(
toolloop.WithEngine(eng),
toolloop.WithRegistry(reg),
toolloop.WithLoopConfig(loopCfg),
toolloop.WithToolConfig(toolCfg),
)
return loop.RunLoop(ctx, seed)
}
The following example shows how to:
tool_use blockspackage main
import (
"context"
"github.com/go-go-golems/geppetto/pkg/inference/engine"
"github.com/go-go-golems/geppetto/pkg/inference/toolloop"
"github.com/go-go-golems/geppetto/pkg/inference/toolloop/enginebuilder"
"github.com/go-go-golems/geppetto/pkg/inference/tools"
"github.com/go-go-golems/geppetto/pkg/turns"
)
type AddRequest struct { A, B float64 `json:"a" jsonschema:"required"` }
type AddResponse struct { Sum float64 `json:"sum"` }
func addTool(req AddRequest) AddResponse { return AddResponse{Sum: req.A + req.B} }
func run(ctx context.Context, e engine.Engine) error {
// 1) Create registry and register the tool
reg := tools.NewInMemoryToolRegistry()
def, _ := tools.NewToolFromFunc("add", "Add two numbers", addTool)
_ = reg.RegisterTool("add", *def)
// 2) Seed a Turn
t := &turns.Turn{}
turns.AppendBlock(t, turns.NewUserTextBlock("Please use add with a=2 and b=3"))
// 3) Configure the tool loop
loopCfg := toolloop.NewLoopConfig().
WithMaxIterations(3)
toolCfg := tools.DefaultToolConfig().
WithMaxParallelTools(1).
WithToolChoice(tools.ToolChoiceAuto).
WithToolErrorHandling(tools.ToolErrorContinue)
// 4) Build a runner that owns tool execution
builder := enginebuilder.New(
enginebuilder.WithBase(e),
enginebuilder.WithToolRegistry(reg),
enginebuilder.WithLoopConfig(loopCfg),
enginebuilder.WithToolConfig(toolCfg),
)
runner, err := builder.Build(ctx, "demo-session")
if err != nil {
return err
}
// 5) Run inference (engine may emit tool_call; tool loop executes and appends tool_use)
_, err = runner.RunInference(ctx, t)
return err
}
Once a provider emits tool_call blocks, a tools.ToolExecutor turns those calls into actual function invocations. Geppetto now ships two composable executors:
tools.DefaultToolExecutor wraps the standard behavior (argument masking, event publishing, retries, and parallelism driven by ToolConfig)tools.BaseToolExecutor provides the orchestration plus overridable lifecycle hooks so you can inject authorization, observability, or custom retry heuristicsThe ToolConfig you attach to the Turn still governs concurrency (MaxParallelTools), error handling (ToolErrorAbort vs ToolErrorRetry), and retry backoff. DefaultToolExecutor simply wires those settings into the base implementation.
If you need custom behavior, embed the base executor and override only the hooks you care about. Remember to point the base executor back to the outer type so the overrides run.
import (
"context"
"encoding/json"
"github.com/go-go-golems/geppetto/pkg/inference/tools"
)
type Session interface {
Bearer() string
}
type AuthorizedExecutor struct {
*tools.BaseToolExecutor
sess Session
}
func NewAuthorizedExecutor(cfg tools.ToolConfig, sess Session) *AuthorizedExecutor {
base := tools.NewBaseToolExecutor(cfg)
exec := &AuthorizedExecutor{BaseToolExecutor: base, sess: sess}
base.ToolExecutorExt = exec // enable hook overrides
return exec
}
func (a *AuthorizedExecutor) PreExecute(ctx context.Context, call tools.ToolCall, _ tools.ToolRegistry) (tools.ToolCall, error) {
// Inject auth into the argument payload before execution
var args map[string]any
_ = json.Unmarshal(call.Arguments, &args)
if args == nil {
args = map[string]any{}
}
args["auth"] = map[string]string{"bearer_token": a.sess.Bearer()}
call.Arguments, _ = json.Marshal(args)
return call, nil
}
func (a *AuthorizedExecutor) MaskArguments(ctx context.Context, call tools.ToolCall) string {
// Redact secrets when events are published
var args map[string]any
_ = json.Unmarshal(call.Arguments, &args)
if auth, ok := args["auth"].(map[string]any); ok {
auth["bearer_token"] = "***"
}
masked, _ := json.Marshal(args)
return string(masked)
}
Available hooks on BaseToolExecutor:
PreExecute mutate or reject calls before lookupIsAllowed add executor-specific authorization checks before executionMaskArguments, PublishStart, PublishResult tune event payloadsShouldRetry implement bespoke retry policiesMaxParallel override concurrency control per batchOverride whichever hooks you need; the base executor handles the rest (context cancellation, event emission, timings, and retries). For most projects, tools.NewDefaultToolExecutor remains sufficient, and higher-level orchestration (via toolloop.Loop or toolloop/enginebuilder) wires it in under the hood.
tools.NewToolFromFunc recognises optional context.Context parameters. Supported signatures include:
func(Input) (Output, error)func(context.Context, Input) (Output, error)func(context.Context) (Output, error) (no JSON payload)func() (Output, error)When you register a tool, Geppetto generates JSON Schema for the first non-context parameter and compiles both context-free and context-aware executors. That means providers that pass Go contexts can propagate deadlines, auth tokens, or tracing spans straight into your tool implementation.
func searchDocs(ctx context.Context, req SearchRequest) (SearchResponse, error) {
span := trace.SpanFromContext(ctx)
span.AddEvent("tool.searchDocs")
return index.Search(ctx, req.Query)
}
def, _ := tools.NewToolFromFunc("search_docs", "Search internal documentation", searchDocs)
If a tool has no JSON input (for example func(context.Context) (Result, error)), the generated schema becomes an empty object so the provider can still advertise the tool.
When reading/writing block payloads, always use the constants:
turns.PayloadKeyText
turns.PayloadKeyID
turns.PayloadKeyName
turns.PayloadKeyArgs
turns.PayloadKeyResult
turns.PayloadKeyError
Engine discovery keys in Turn.Data:
engine.KeyToolConfig // engine.ToolConfig stored in Turn.Data
Tools can emit custom progress or status events using the event registry. This is useful for long-running operations where you want to provide real-time feedback to users:
import "github.com/go-go-golems/geppetto/pkg/events"
type ToolProgressEvent struct {
events.EventImpl
ToolName string `json:"tool_name"`
Progress float64 `json:"progress"`
Message string `json:"message"`
}
func init() {
_ = events.RegisterEventFactory("tool-progress", func() events.Event {
return &ToolProgressEvent{EventImpl: events.EventImpl{Type_: "tool-progress"}}
})
}
func longRunningTool(ctx context.Context, req ToolRequest) (ToolResponse, error) {
// Emit progress events
progressEvent := &ToolProgressEvent{
EventImpl: events.EventImpl{Type_: "tool-progress", Metadata_: metadata},
ToolName: "long_running_tool",
Progress: 0.5,
Message: "Processing data...",
}
events.PublishEventToContext(ctx, progressEvent)
// ... tool implementation
}
For details on event extensibility, see: glaze help geppetto-events-streaming-watermill
| Problem | Solution |
|---|---|
| Tools not advertised | Ensure ctx = tools.WithRegistry(ctx, reg) before RunInference |
| Tool call not executed | Check middleware is attached or run the tool loop explicitly via toolloop.New(...).RunLoop(...) |
| Payload key errors | Use constants like turns.PayloadKeyArgs, never string literals |
| Dynamic tools not working | Modify registry before calling RunInference; Turn.Data for config only |