Skip to content

Tool calling

Tool calling, also known as function calling, is a structured way to give LLMs the ability to make requests back to the application that called it. You define the tools you want to make available to the model, and the model will make tool requests to your app as necessary to fulfill the prompts you give it.

The use cases of tool calling generally fall into a few themes:

Giving an LLM access to information it wasn’t trained with

  • Frequently changing information, such as a stock price or the current weather.
  • Information specific to your app domain, such as product information or user profiles.

Note the overlap with retrieval augmented generation (RAG), which is also a way to let an LLM integrate factual information into its generations. RAG is a heavier solution that is most suited when you have a large amount of information or the information that’s most relevant to a prompt is ambiguous. On the other hand, if a function call or database lookup is all that’s necessary for retrieving the information the LLM needs, tool calling is more appropriate.

Introducing a degree of determinism into an LLM workflow

  • Performing calculations that the LLM cannot reliably complete itself.
  • Forcing an LLM to generate verbatim text under certain circumstances, such as when responding to a question about an app’s terms of service.

Performing an action when initiated by an LLM

  • Turning on and off lights in an LLM-powered home assistant
  • Reserving table reservations in an LLM-powered restaurant agent

If you want to run the code examples on this page, first complete the steps in the Get started guide. All of the examples assume that you have already set up a project with Genkit dependencies installed.

This page discusses one of the advanced features of Genkit model abstraction, so before you dive too deeply, you should be familiar with the content on the Generating content with AI models page. You should also be familiar with Genkit’s system for defining input and output schemas, which is discussed on the Flows page.

At a high level, this is what a typical tool-calling interaction with an LLM looks like:

  1. The calling application prompts the LLM with a request and also includes in the prompt a list of tools the LLM can use to generate a response.
  2. The LLM either generates a complete response or generates a tool call request in a specific format.
  3. If the caller receives a complete response, the request is fulfilled and the interaction ends; but if the caller receives a tool call, it performs whatever logic is appropriate and sends a new request to the LLM containing the original prompt or some variation of it as well as the result of the tool call.
  4. The LLM handles the new prompt as in Step 2.

For this to work, several requirements must be met:

  • The model must be trained to make tool requests when it’s needed to complete a prompt. Most of the larger models provided through web APIs such as Gemini can do this, but smaller and more specialized models often cannot. Genkit returns an error if you try to provide tools to a model that doesn’t support it.
  • The calling application must provide tool definitions to the model in the format it expects.
  • The calling application must prompt the model to generate tool calling requests in the format the application expects.

Genkit provides a single interface for tool calling with models that support it. Each model plugin ensures that the last two criteria mentioned in the previous section are met, and the genkit.Generate() function automatically carries out the tool-calling loop described earlier.

Tool calling support depends on the model, the model API, and the Genkit plugin. Consult the relevant documentation to determine if tool calling is likely to be supported. In addition:

  • Genkit returns an error if you try to provide tools to a model that doesn’t support it.
  • If the plugin exports model references, the ModelInfo.Supports.Tools property will indicate if it supports tool calling.

Use the genkit.DefineTool() function to write tool definitions:

package main
import (
"context"
"fmt"
"log"
"github.com/firebase/genkit/go/ai"
"github.com/firebase/genkit/go/genkit"
"github.com/firebase/genkit/go/plugins/googlegenai"
)
// WeatherInput is the tool's input. The schema the model sees is inferred from
// this type, so a jsonschema_description tag is how a field gets described.
type WeatherInput struct {
Location string `json:"location" jsonschema_description:"The location to get the current weather for."`
}
func main() {
ctx := context.Background()
g := genkit.Init(ctx,
genkit.WithPlugins(&googlegenai.GoogleAI{}),
genkit.WithDefaultModel("googleai/gemini-flash-latest"),
)
getWeather := genkit.DefineTool(g, "getWeather",
"Gets the current weather in a given location",
func(ctx *ai.ToolContext, input WeatherInput) (string, error) {
// Here, we would typically make an API call or database query. For
// this example, we just return a fixed value.
return fmt.Sprintf("The current weather in %s is 63°F and sunny.", input.Location), nil
})
resp, err := genkit.Generate(ctx, g,
ai.WithPrompt("What is the weather in San Francisco?"),
ai.WithTools(getWeather),
)
if err != nil {
log.Fatal(err)
}
fmt.Println(resp.Text())
}

The syntax looks like genkit.DefineFlow(), with one addition: you must write a description. The name and the description are all the model knows about a tool besides the schemas inferred from the input and output types, so both are prompt and are worth writing as carefully as one.

Describe fields with the jsonschema_description struct tag. The jsonschema tag is a comma-separated keyword list for constraints such as enum= and minimum=, so a description written there is cut off at its first comma with no error; the separate tag has no list to terminate.

*ai.ToolContext embeds context.Context, so it satisfies context.Context directly. Pass it unchanged to a flow, an HTTP client, or a database handle, and it carries the caller’s cancellation and deadline with it. This one needs time on top of the imports above:

type RevenueInput struct {
Region string `json:"region"`
}
reportRevenue := genkit.DefineFlow(g, "reportRevenue",
func(ctx context.Context, region string) (string, error) {
// ... look up the numbers ...
return "revenue for " + region, nil
})
getRevenue := genkit.DefineTool(g, "getRevenue",
"Reports revenue for a region over the last quarter.",
func(ctx *ai.ToolContext, input RevenueInput) (string, error) {
// ctx is a context.Context, so it goes straight into anything that
// takes one. Give a slow dependency a deadline of its own.
callCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
return reportRevenue.Run(callCtx, input.Region)
})

For the same reason, the tool context goes straight into the runtime verbs of package github.com/firebase/genkit/go/ai/tool, which take a context.Context. They let a tool attach content parts to its answer, stream progress, and return an error to the model, all without a change to its signature. The sections below show each one. A tool that pauses generation to ask the caller something is defined with genkit.DefineResumableTool() instead, which the interrupts guide covers.

When a model asks for several tools in one turn, Genkit runs them concurrently, one goroutine per request. A tool function must be safe to call from several goroutines at once: guard shared state with a mutex, and copy a slice or map before returning it. The registry is mutex-protected, so genkit.DefineTool and the other Define* calls are safe from any goroutine, but defining tools during startup is still the pattern to follow.

If the input shape is only known at runtime, declare the input parameter as any and pass the schema instead. ai.WithInputSchema(schema map[string]any) takes a plain JSON Schema document as a map[string]any, not a *jsonschema.Schema, not []byte, and not a struct:

schema := map[string]any{
"type": "object",
"properties": map[string]any{
"location": map[string]any{
"type": "string",
"description": "The location to get the current weather for",
},
},
"required": []string{"location"},
}
genkit.DefineTool(g, "getWeather", "Gets the current weather in a given location",
func(ctx *ai.ToolContext, input any) (string, error) {
// input arrives as a map[string]any shaped by the schema above.
return "sunny", nil
},
ai.WithInputSchema(schema),
)

ai.WithInputSchemaName references a schema registered with genkit.DefineSchemasFor, and ai.WithOutputSchema(schema map[string]any) and ai.WithOutputSchemaName do the same for the output. An explicit schema stands in for a type parameter, so the parameter it describes must be any.

The basic-tools sample is a runnable program built around one tool, and it is where the deployment example further down comes from.

Include defined tools in your prompts to generate content.

Using genkit.Generate():

resp, err := genkit.Generate(ctx, g,
ai.WithPrompt("What is the weather in San Francisco?"),
ai.WithTools(getWeather),
)

ai.WithTools(tools ...ai.ToolRef) accepts anything with a Name() string method: the *ai.ToolAction that genkit.DefineTool returns, the *ai.ResumableToolAction that genkit.DefineResumableTool returns, and ai.ToolName("getWeather") when all you have is the name. So a per-request tool set is just a slice you spread. Repeating the option appends, and duplicate names are rejected when the request runs:

// auditLog is a tool registered elsewhere, for example by a plugin.
adminTools := []ai.ToolRef{getWeather, getRevenue, ai.ToolName("auditLog")}
resp, err := genkit.Generate(ctx, g,
ai.WithPrompt("Who looked at the San Francisco forecast today?"),
ai.WithTools(adminTools...),
)

The model receives the tool definitions in the order you list them, followed by any tools that middleware adds. Two identical calls send identical requests, so provider-side prompt caching keeps working across turns.

Using genkit.DefinePrompt():

weatherPrompt := genkit.DefinePrompt(g, "weatherPrompt",
ai.WithPrompt("What is the weather in {{location}}?"),
ai.WithTools(getWeather),
)
resp, err := weatherPrompt.Execute(ctx,
ai.WithInput(map[string]any{"location": "San Francisco"}),
)

Using a .prompt file:

Create a file named prompts/weatherPrompt.prompt (assuming default prompt directory):

---
system: "Answer questions using the tools you have."
tools: [getWeather]
input:
schema:
location: string
---
What is the weather in {{location}}?

Then execute it in your Go code:

// Assuming prompt file named weatherPrompt.prompt exists in ./prompts dir.
weatherPrompt := genkit.LookupPrompt(g, "weatherPrompt")
if weatherPrompt == nil {
log.Fatal("no prompt named 'weatherPrompt' found")
}
resp, err := weatherPrompt.Execute(ctx,
ai.WithInput(map[string]any{"location": "San Francisco"}),
)

Genkit handles the tool call automatically if the LLM needs to use the getWeather tool to answer the prompt.

ai.WithToolChoice() decides whether the model may call a tool, must call one, or must not. ai.ToolChoice is a string type with three values:

ValueMeaning
ai.ToolChoiceAutoThe model decides whether to call a tool.
ai.ToolChoiceRequiredThe model must call at least one tool this turn.
ai.ToolChoiceNoneThe model must answer without calling a tool.
resp, err := genkit.Generate(ctx, g,
ai.WithPrompt("What is the weather in San Francisco?"),
ai.WithTools(getWeather),
ai.WithToolChoice(ai.ToolChoiceRequired),
)

Omitting the option leaves the zero value, the empty string, which means unset: Genkit sends nothing and the provider’s own default applies. A model whose ModelInfo.Supports.ToolChoice is false accepts only the unset value and ai.ToolChoiceAuto; anything else fails the request with ai.ErrUnsupportedByModel.

A tool sometimes has more to say than a single value: a chart of what it measured, a screenshot of what it saw, a document it retrieved. Call tool.AttachParts() from inside the tool to add content parts to its response. The tool keeps its own return type, so the output schema the model sees is still the one inferred from that type:

import (
"fmt"
"github.com/firebase/genkit/go/ai"
"github.com/firebase/genkit/go/ai/tool"
"github.com/firebase/genkit/go/genkit"
)
// Deploy is what the model fills in to call the tool.
type Deploy struct {
Service string `json:"service" jsonschema_description:"The service to deploy."`
Environment string `json:"environment" jsonschema:"enum=staging,enum=production" jsonschema_description:"Where to deploy it."`
}
// Rollout is the tool's answer, and the whole of its return type.
type Rollout struct {
Service string `json:"service"`
Revision string `json:"revision"`
Healthy bool `json:"healthy"`
P95Ms float64 `json:"p95Ms" jsonschema_description:"The p95 latency after the rollout, in milliseconds."`
}
deployService := genkit.DefineTool(g, "deployService",
"Deploys a service to an environment and reports how the rollout went.",
func(ctx *ai.ToolContext, input Deploy) (*Rollout, error) {
latencies, err := rollOut(input)
if err != nil {
return nil, err
}
// The model receives this as a picture, so it can describe the
// shape of the rollout rather than only its last number.
tool.AttachParts(ctx, ai.NewMediaPart("image/png", barChartPNG(latencies)))
return &Rollout{
Service: input.Service,
Revision: fmt.Sprintf("%s-00042", input.Service),
Healthy: true,
P95Ms: latencies[len(latencies)-1],
}, nil
})

rollOut and barChartPNG stand for your own code: rollOut runs the deployment and returns its latencies, and barChartPNG renders them as a data:image/png;base64,... URL, the form ai.NewMediaPart() takes for inline media.

The attached parts reach the model and the client both, so the model can reason about the chart and the Dev UI can render it. Attach media or data parts: providers such as Gemini reject a text part in a tool response. The parts are added to the response when the tool returns, so attach them before then. A goroutine the tool starts can attach parts while the tool waits for it, but one that outlives the call attaches to nothing. A WrapTool hook in middleware can call tool.AttachParts() too.

A tool call takes several turns, so a stream carries the tool’s traffic as well as the model’s text. A slow tool can also stream progress while it works with tool.SendChunk(). The tool builds the chunk itself; set its Role to ai.RoleTool so the caller can tell it from the model’s own text:

// Inside the deployService tool, before each step of the rollout.
tool.SendChunk(ctx, &ai.ModelResponseChunk{
Role: ai.RoleTool,
Content: []*ai.Part{ai.NewTextPart("shifting 50% of traffic")},
})

Streaming is best effort. With a caller that is not streaming, SendChunk is a no-op, and the value the tool returns is always the authoritative answer. Progress is not written to history, since it is for showing rather than for the model to read.

Use genkit.GenerateStream() when the caller needs to act on that traffic, and switch on the part kind. core.StreamCallback and genkit.DefineStreamingFlow come from the flows API, and status here is github.com/firebase/genkit/go/core/status, not google.golang.org/grpc/status; see Error types:

import (
"context"
"fmt"
"github.com/firebase/genkit/go/ai"
"github.com/firebase/genkit/go/core"
"github.com/firebase/genkit/go/core/status"
"github.com/firebase/genkit/go/genkit"
)
genkit.DefineStreamingFlow(g, "deployFlow",
func(ctx context.Context, input string, sendChunk core.StreamCallback[string]) (string, error) {
for val, err := range genkit.GenerateStream(ctx, g,
ai.WithPrompt(input),
ai.WithTools(deployService),
) {
if err != nil {
return "", fmt.Errorf("could not deploy: %w", err)
}
if val.Done {
return val.Response.Text(), nil
}
for _, part := range val.Chunk.Content {
switch {
case part.IsText() && val.Chunk.Role == ai.RoleTool:
sendChunk(ctx, "deploy: "+part.Text) // Progress from tool.SendChunk.
case part.IsText():
sendChunk(ctx, part.Text) // The model's own text.
case part.IsToolRequest():
sendChunk(ctx, fmt.Sprintf("[calling %s]", part.ToolRequest.Name))
case part.IsToolResponse():
sendChunk(ctx, fmt.Sprintf("[%s answered]", part.ToolResponse.Name))
}
}
}
return "", status.Errorf(status.ErrInternal, "the stream ended without a final result")
})

A core.StreamCallback[*ai.ModelResponseChunk] handed to ai.WithStreaming() on an ordinary genkit.Generate() call is shorter and forwards every chunk untouched. The tool traffic still arrives on it, so the caller switches on the part kind the same way.

Limiting tool call iterations with WithMaxTurns

Section titled “Limiting tool call iterations with WithMaxTurns”

When a tool might trigger several sequential calls, ai.WithMaxTurns() caps how many back-and-forth iterations the model gets in one generation. It controls cost, keeps latency bounded, and guards against a loop that never settles. Each turn is one complete cycle of the model calling tools and receiving their responses. The default is 50:

resp, err := genkit.Generate(ctx, g,
ai.WithPrompt("Research the latest developments in quantum computing."),
ai.WithTools(webSearch),
ai.WithMaxTurns(8), // Allow up to 8 tool-calling iterations.
)
if err != nil {
if errors.Is(err, ai.ErrMaxTurnsExceeded) {
// The loop hit its limit before the model produced a final answer.
// resp.History() holds the rounds that completed; send it back with
// a higher limit to continue rather than start over.
}
}

When the limit is reached, Genkit stops the loop and returns an error matching ai.ErrMaxTurnsExceeded, whose status is ABORTED, beside a partial response: resp.FinishReason is ai.FinishReasonAborted, since a limit the caller set is a caller stop, and resp.History() carries the completed rounds. Either raise the limit or look for a tool the model keeps retrying.

A tool that returns a non-nil error is fail-fast by default. Genkit stops the tool loop as soon as the error arrives, while any siblings in the same round finish on their own, and genkit.Generate() returns the error wrapped as ai.ErrToolFailed, a subtype of status.ErrInternal (HTTP 500). The model never sees it and never retries. The response comes back beside the error with ai.FinishReasonFailed: its History() holds the rounds before the failure and drops the failed round whole, the model’s request message included, because a conversation cannot end on an unanswered tool request. Output that does not match the tool’s declared schema reports the same sentinel, and so does input the model sent that fails the declared input schema: the tool function never runs and the call aborts with ai.ErrToolFailed wrapping status.ErrInvalidInput. A tool that stopped because the call’s context ended is not a tool failure: that error carries CANCELLED, and the response reports ai.FinishReasonAborted.

A tool that panics fails the same way. The panic becomes the tool’s own error, with the fixed message tool panicked. The panic value and its stack go only to the error log, because they can hold anything the tool had in hand, secrets included.

The tool’s own error is wrapped with %w, so both the framework sentinel and your own cause still match:

// errStationOffline is the tool's own sentinel, returned from its body.
var errStationOffline = errors.New("weather station offline")
resp, err := genkit.Generate(ctx, g,
ai.WithPrompt("What is the weather in San Francisco?"),
ai.WithTools(getWeather),
)
if err != nil {
if errors.Is(err, ai.ErrToolFailed) && errors.Is(err, errStationOffline) {
return fmt.Errorf("retry later: %w", err)
}
return err
}

Some errors are the model’s to fix: a misspelled name, a query with a syntax error, a record that does not exist. Return them with tool.Fail(ctx, err). The loop answers the call with the error and continues, so the model reads the message and can call the tool again with better input. Every other error still stops the loop, so a tool decides error by error:

// errUnknownLocation is returned by readStation, your own call to the
// weather service, when it has no station for a location.
var errUnknownLocation = errors.New("unknown location")
getWeather := genkit.DefineTool(g, "getWeather",
"Gets the current weather in a given location.",
func(ctx *ai.ToolContext, input WeatherInput) (string, error) {
forecast, err := readStation(ctx, input.Location)
if errors.Is(err, errUnknownLocation) {
// The model can fix this one: it reads the message and tries
// another name.
return "", tool.Fail(ctx, fmt.Errorf("no station for %q; use a city name", input.Location))
}
// Any other error, such as errStationOffline, still stops the loop.
return forecast, err
})

The model receives the error message as the tool’s output, {"error": "..."}, and model plugins whose API marks a failed call, such as Anthropic’s is_error, mark it. In the history, part.IsToolError() reports true on that tool response. ai.IsToolFailError(err) reports whether an error was made with tool.Fail(), which is how a test checks that a tool returns an error to the model.

For tools you do not own, such as tools from an MCP server or tools that middleware adds, the SoftToolErrors middleware from github.com/firebase/genkit/go/plugins/middleware returns their errors to the model in the same way. Tools limits it to some tool names. Left empty, it covers every tool, and calls to tool names that do not exist:

resp, err := genkit.Generate(ctx, g,
ai.WithPrompt("Find the cheapest flight to Lisbon next Friday."),
ai.WithTools(searchFlights, bookFlight), // tools defined with genkit.DefineTool
// Errors from searchFlights go to the model; bookFlight stays fail-fast.
ai.WithUse(&middleware.SoftToolErrors{Tools: []string{"searchFlights"}}),
)

It covers the tool’s own errors, input validation errors included. Interrupts, cancellation, and an error that a WrapTool hook returns in place of the tool’s error still stop the generation. Other WrapTool hooks, such as a retry, see the tool’s error first, wherever SoftToolErrors sits in the chain.

See Error types for the rest of the sentinels generation reports.

There is no resp.ToolCalls(). resp.ToolRequests() returns only the requests left unfulfilled by the final turn, which is empty unless ai.WithReturnToolRequests(true) is set or an interrupt fired. The full record of the loop is resp.History(): the request’s messages plus the response, which includes each model message carrying tool requests and each ai.RoleTool message carrying their responses. Pair the two on Ref:

calls := map[string]*ai.ToolRequest{}
for _, msg := range resp.History() {
for _, part := range msg.Content {
switch {
case part.IsToolRequest():
calls[part.ToolRequest.Ref] = part.ToolRequest
case part.IsToolResponse():
req := calls[part.ToolResponse.Ref]
fmt.Printf("%s(%v) -> %v\n", part.ToolResponse.Name, req.Input, part.ToolResponse.Output)
}
}
}

Not every tool is one you write. Middleware attached with ai.WithUse() can contribute tools to a request, and they join the loop like any other. The Filesystem middleware from github.com/firebase/genkit/go/plugins/middleware adds list_files and read_file, plus write_file and edit_file when AllowWriteAccess is set, all confined to one directory:

resp, err := genkit.Generate(ctx, g,
ai.WithPrompt("What is in the config file, and what does it configure?"),
// Adds list_files and read_file, both confined to ./workspace.
ai.WithUse(&middleware.Filesystem{RootDir: "./workspace"}),
// Exploring a directory takes several rounds, so leave the loop room.
ai.WithMaxTurns(20),
)

A failed file operation, such as reading a directory as a file, answers the call with its error, as SoftToolErrors does, so the model can correct the path and try again.

The Skills middleware lists the available skills in the system prompt and adds tools that load a skill only when the model asks for it, so the heavy instructions stay off the hot path. Both are worked through in the filesystem and skills samples, and the middleware guide covers the rest of the built-in set.

By default, Genkit repeatedly calls the LLM until every tool call has been resolved. You can conditionally pause execution in situations where you want to, for example:

  • Ask the user a question or display UI.
  • Confirm a potentially risky action with the user.
  • Request out-of-band approval for an action.

Interrupts halt the loop and return control to your code so that you can handle more advanced scenarios. A tool defined with genkit.DefineResumableTool() pauses by returning tool.Interrupt(), and resumes with the caller’s answer as a typed parameter. Visit the interrupts guide to learn how to use them.

If you want full control over this tool-calling loop, for example to apply more complicated logic, set the WithReturnToolRequests() option to true. Now it’s your responsibility to ensure all of the tool requests are fulfilled, to carry the tools and the option onto every call, and to stop. One round is not enough: after you answer a tool request the model usually has more to say, and it may ask for another tool:

getWeather := genkit.DefineTool(g, "getWeather",
"Gets the current weather in a given location",
func(ctx *ai.ToolContext, input WeatherInput) (string, error) {
// Tool implementation...
return "sunny", nil
})
const maxTurns = 5
messages := []*ai.Message{
ai.NewUserTextMessage("What is the weather in San Francisco?"),
}
for turn := 0; turn < maxTurns; turn++ {
resp, err := genkit.Generate(ctx, g,
ai.WithMessages(messages...),
// Both options belong on every call, not just the first: without
// them the model has no tools on the second turn.
ai.WithTools(getWeather),
ai.WithReturnToolRequests(true),
)
if err != nil {
log.Fatal(err)
}
requests := resp.ToolRequests()
if len(requests) == 0 {
fmt.Println(resp.Text())
return
}
// ToolRequests returns the request parts, so the call itself is on
// part.ToolRequest.
var parts []*ai.Part
for _, part := range requests {
req := part.ToolRequest
tool := genkit.LookupTool(g, req.Name)
if tool == nil {
log.Fatalf("tool %q not found", req.Name)
}
output, err := tool.RunRaw(ctx, req.Input)
if err != nil {
log.Fatalf("tool %q failed: %v", tool.Name(), err)
}
parts = append(parts,
ai.NewToolResponsePart(&ai.ToolResponse{
Name: req.Name,
Ref: req.Ref,
Output: output,
}))
}
// Carry the whole turn forward: the model's request message, then the
// tool message answering it.
messages = append(resp.History(), ai.NewMessage(ai.RoleTool, nil, parts...))
}
log.Fatalf("gave up after %d turns", maxTurns)

ai.WithMaxTurns() has nothing to bound in this mode, since Genkit is no longer running the loop, so the turn cap is yours to enforce.

RunRaw returns the tool’s output value. Use RunRawMultipart instead when you also need the content parts the tool attached with tool.AttachParts(); they go in the Content field of the tool response. An error a tool made with tool.Fail() comes back from both like any other error, so check it with ai.IsToolFailError() and answer the call with the message if the model should see it.