agents-go
Building agents

Tools

Typed function tools with schema-from-types, toolsets, and per-reply scoping.

A tool lets the model do something — look up an order, book a table, hand off to another agent. In agents-go a tool is a typed Go function; its JSON Schema is derived from the input type by reflection, so there's no schema to hand-write or keep in sync. The usual tool bug — the schema the model sees and the code that runs drifting apart — simply can't happen: the contract is the type.

Define a tool

Define the input and output types

Fields become parameters. Struct tags add the model-visible description and validation constraints (description, min/max, enum, format, pattern).

type WeatherInput struct {
    City  string `json:"city"  description:"city name" min:"1"`
    Units string `json:"units" description:"temperature units" enum:"celsius|fahrenheit"`
}

type WeatherOutput struct {
    Summary string  `json:"summary"`
    TempC   float64 `json:"temp_c"`
}

Write the handler

The signature is func(context.Context, *tool.RunContext, *In) (*Out, error). Return an error to tell the model the call failed; the runtime surfaces it as the tool result so the model can recover.

getWeather, err := tool.New("get_weather",
    func(ctx context.Context, run *tool.RunContext, in *WeatherInput) (*WeatherOutput, error) {
        // ... call your weather API ...
        return &WeatherOutput{Summary: "clear", TempC: 21.5}, nil
    },
    tool.WithDescription("Get the current weather for a city."),
)
if err != nil {
    return err
}

The schema is derived once at construction and cached. It's deterministic — sorted keys, stable ordering — so it's safe to snapshot.

Attach it to an agent

AgentOptions.Tools accepts a single tool, a []tool.Entry, a toolset, or a *tool.Context.

agent, err := agents.NewAgent(agents.AgentOptions{
    Instructions: "Help users with the weather. Use get_weather for live data.",
    Tools:        []tool.Entry{getWeather},
})
if err != nil {
    log.Fatal(err)
}

When the model calls the tool during a turn, the runtime decodes the arguments into *WeatherInput, runs your handler, and folds the result back into the reply (Speech & the turn).

RunContext: reach the turn and request-scoped data

The handler's *tool.RunContext is the tool's handle on the live turn:

  • run.WaitForPlayout(ctx) — finish speaking this step before continuing.
  • run.DisallowInterruptions() — make this step's speech uninterruptible.
  • Hand off or delegate — agents.RequestHandoff(run, target, returns) / agents.DelegateTask(ctx, run, task) (Agents & handoffs).

Set Config.UserData (an any) to pass request-scoped dependencies — a DB handle, the authenticated user, a tenant ID — into your tools, and read it back off run.UserData:

type deps struct{ db *sql.DB; userID string }

session, err := agents.NewSession(agents.Config{
    LLM:      "openai/gpt-4o",
    UserData: &deps{db: db, userID: "u_123"},
})
if err != nil {
    log.Fatal(err)
}

lookup, err := tool.New("lookup_orders",
    func(ctx context.Context, run *tool.RunContext, _ *struct{}) (*Orders, error) {
        d := run.UserData.(*deps) // the value set on the session
        return queryOrders(ctx, d.db, d.userID)
    },
)
if err != nil {
    log.Fatal(err)
}

WaitForPlayout holds up the turn until the audio finishes. Use it only when ordering against speech matters ("say it, then act") — not by default.

Restrict tools for one reply

You can limit which tools a single reply may call, by tool ID (the function name), without changing the agent — useful to force a step:

session.GenerateReply(agents.GenerateReplyOptions{
    UserInput: "Book it.",
    Tools:     []string{"book_table"}, // only this tool available this reply
})

See Speech & the turn for other per-reply controls.

Toolsets: group tools that share a lifecycle

A toolset groups related tools that share setup and teardown — a database pool, an MCP client, an authenticated API session. Build one with tool.NewStaticToolset (no lifecycle) or tool.NewToolset (with Setup/Close hooks that run off the turn path). A toolset is accepted anywhere a tool is:

billing, err := tool.NewStaticToolset("billing", issueRefund, getInvoice)
if err != nil {
    log.Fatal(err)
}

agent, err := agents.NewAgent(agents.AgentOptions{
    Instructions: "Help with billing.",
    Tools:        []any{billing, escalate}, // toolsets and tools mix freely
})
if err != nil {
    log.Fatal(err)
}

Toolsets are flattened before tools are sent to the model, and tool names must be unique across everything mounted.

Swap tools at runtime

The active tool list isn't fixed. Agent.UpdateTools (or AgentContext.UpdateTools from inside a hook) replaces it mid-session — the natural home is OnUserTurnCompleted, where you can classify the user's intent and narrow the tool surface to just that domain before the model generates:

agent, err := agents.NewAgent(agents.AgentOptions{
    Instructions: "…",
    OnUserTurnCompleted: func(ctx context.Context, ac *agents.AgentContext, _ *llm.ChatContext, msg *llm.ChatMessage) error {
        domain := classify(msg.TextContent())     // your keyword/embedding classifier
        return ac.UpdateTools(ctx, toolsetFor(domain))
    },
})
if err != nil {
    log.Fatal(err)
}

Showing a model hundreds of tools is slow and error-prone. tool.NewToolSearchToolset puts a whole catalogue behind a single tool_search tool: the model searches by intent, and only the matching tools are loaded and become callable.

catalog, err := tool.NewToolSearchToolset(tool.ToolSearchOptions{
    ID:         "catalog",
    Tools:      []any{billing, orders, account}, // any number of toolsets
    MaxResults: 5,
})
if err != nil {
    log.Fatal(err)
}

agent, err := agents.NewAgent(agents.AgentOptions{
    Instructions: "Use tool_search to find the right tool, then call it.",
    Tools:        []any{catalog},
})
if err != nil {
    log.Fatal(err)
}

A related variant, tool.NewToolProxyToolset, exposes a fixed pair — tool_search plus call_tool — so remote tools (e.g. from an MCP server) appear as native ones without ever being individually mounted.

Custom ranking with SearchStrategy

How search ranks candidates is pluggable via the tool.SearchStrategy interface (BuildIndex / Search / Cleanup). The default is BM25; tool.NewKeywordSearchStrategy is also built in. To scale past what lexical matching handles — hundreds of tools, ranked by meaning — implement the interface with an embedding model and cosine similarity, caching each tool's vector on SearchItem.IndexData (persist them in pgvector for a large catalogue):

type embeddingSearch struct{ /* your embedder */ }

func (s *embeddingSearch) BuildIndex(ctx context.Context, items []*tool.SearchItem) error {
    for _, it := range items {
        it.IndexData = s.embed(ctx, it.Name+" "+it.Description) // []float32
    }
    return nil
}
func (s *embeddingSearch) Search(ctx context.Context, query string, items []*tool.SearchItem, n int) ([]*tool.SearchItem, error) {
    // embed(query), cosine against each it.IndexData, sort desc, return top-n
}
func (s *embeddingSearch) Cleanup(context.Context) error { return nil }

Pass it as ToolSearchOptions.SearchStrategy. A complete, runnable cosine strategy is in examples/toolsearch; the supervisor pattern page shows the agent side.

Runtime tools with no Go type (MCP-provided, fully dynamic) use tool.RawTool, which carries an explicit schema instead of a reflected one.

Long-running tools

When a tool takes a while (a slow API, a lookup), a voice agent shouldn't go silent. Three controls on the tool and its RunContext handle this:

  • AgentOptions.ToolFiller — interstitial speech ("let me check that…") played after ToolFillerDelay while the tool runs.
  • run.WaitForPlayout(ctx) — finish speaking the current step before the tool's result advances the turn ("say it, then act").
  • run.DisallowInterruptions() — make this step's speech uninterruptible, for a confirmation that must not be cut off.

Next

On this page