This series has so far covered two providers: OpenAI, across Chat Completions and Responses, and Anthropic, across the Messages API and its output parameters. Gemini is the third provider worth knowing in Go, and it comes with its own set of design decisions — a different SDK, a different way of managing conversation history, and, as of recently, two separate APIs doing largely the same job.
This article builds the same conversational agent from earlier in the series, this time on top of Google’s Gemini API, using the genai Go SDK. By the end, you will have a working agent that maintains conversation history and can call external tools to answer questions it otherwise could not — and a clear picture of which of Gemini’s two APIs to reach for.
A short introduction to Gemini#
The path to large language models runs through a decade of incremental progress in deep learning. Early models like word2vec and GloVe learned to embed words into dense vector spaces, capturing semantic relationships between terms. The transformer architecture, introduced by Google in 2017, changed the trajectory of the field — it processes sequences in parallel using attention mechanisms that capture long-range dependencies far more effectively than recurrent networks. This architectural shift made it practical to train models on orders of magnitude more data. GPT-1 in 2018 showed that large-scale unsupervised pre-training followed by fine-tuning could match or beat purpose-built models across a range of language tasks.
Understanding what these models actually do removes a lot of the mysticism around them. An LLM is, at its core, a next-token predictor. It takes a sequence of tokens as input and outputs a probability distribution over the vocabulary for the next token. The transformer’s attention mechanism allows every token in the input to attend to every other token, building a rich contextual representation before making that prediction. Training adjusts billions of parameters to minimise prediction error across enormous text corpora. What emerges is a model with broad world knowledge encoded in its weights — not because it was taught facts directly, but because predicting text well requires internalising the structure of the world that produced that text.
Gemini is Google DeepMind’s model family, introduced in late 2023 as the first model built jointly by Google Brain and DeepMind after the two research groups merged. Unlike earlier generations of models that had multimodal capabilities bolted on top of a text-only foundation, Gemini was trained natively across text, images, audio, and video from the start, which is part of why it handles mixed-input tasks and tool use without the awkward seams you sometimes see in models where multimodality was added later. For developers, Google exposes Gemini through the Gemini API — and, as of recently, through two distinct interfaces for that API, which is the subject of the next section.
Generate Content API vs Interactions API#
Google exposes its models through two APIs today. The Generate Content API is the original, more fundamental of the
two — request/response calls where the caller supplies the full conversation history, or lets the higher-level Chats
helper in the Go SDK accumulate that history locally. The server has no memory of previous calls unless you resend it.
It is the interface this article’s code examples use.
The Interactions API, more recently made generally available, moves conversation state onto Google’s servers. Instead
of resending history, you reference a previous_interaction_id from an earlier call and the server retrieves the
context automatically. It also adds background execution and a single unified endpoint for calling both plain Gemini
models and Google’s specialised agents. The trade-off is the one every server-managed conversation API makes: less
code on your side, less visibility into exactly what gets sent to the model on each turn, and — since Google retains
interaction data for a fixed window (55 days on paid tiers, 1 day on the free tier, unless you opt out) — a
data-retention decision you don’t have to make with a fully stateless approach.
| Feature | Generate Content API | Interactions API |
|---|---|---|
| Conversation state | Client-managed, or via the Chats helper | Server-managed via previous_interaction_id |
| History management | Manual — resent with every request, unless using Chats | Automatic, referenced by ID |
| Tool support | Manual function calling | Unified tool and agent invocation |
| Background execution | No | Yes |
| Data retention | None — nothing stored server-side | 55 days (paid), 1 day (free), unless store=false |
| Control | Full | Reduced |
| Best for | Custom agents, full control, simplicity | Long-running interactions, agent orchestration |
In this article we use the Generate Content API — specifically the Chats helper in the Go SDK, which keeps the
client-side ergonomics of automatic history management while staying on the simpler, stateless-per-request model underneath.
First AI agent#
Three things happen here: the API client is created, a chat session is created, and that chat session is stored in a
package-level variable so talkToAgent can reach it later. The client is created with the API key read from the
environment — if the variable is missing, the application exits immediately rather than failing later with a cryptic
authentication error. Unlike the OpenAI and Anthropic examples earlier in this series, there is no history slice here
at all.
package main
import (
// ...
"google.golang.org/genai"
)
var chat *genai.Chat
func main() {
apiKey := os.Getenv("GEMINI_API_KEY")
if apiKey == "" {
fmt.Fprintln(os.Stderr, "error: GEMINI_API_KEY environment variable is not set")
os.Exit(1)
}
ctx := context.Background()
client, err := genai.NewClient(ctx, &genai.ClientConfig{
APIKey: apiKey,
})
if err != nil {
fmt.Fprintf(os.Stderr, "error: failed to create client: %v\n", err)
os.Exit(1)
}
chat, err = client.Chats.Create(ctx, "gemini-3.6-flash", &genai.GenerateContentConfig{
SystemInstruction: &genai.Content{
Parts: []*genai.Part{genai.NewPartFromText("You are a helpful assistant.")},
},
}, nil)
if err != nil {
fmt.Fprintf(os.Stderr, "error: failed to create chat: %v\n", err)
os.Exit(1)
}
}The Go SDK’s Chats helper wraps the lower-level Generate Content calls and manages conversation history
internally — you get a *genai.Chat back from client.Chats.Create, and every subsequent call to chat.SendMessage
both sends the new turn and stores it in that chat’s history automatically. The system instruction and the model name
are also fixed at creation time, on the GenerateContentConfig passed to Chats.Create, rather than being repeated —
or, in Anthropic’s case, injected — on every individual request.
import (
"bufio"
// ...
)
func talkToAgent(input string) (string, error) {
return "", nil
}
func main() {
// ...
scanner := bufio.NewScanner(os.Stdin)
fmt.Println("AI assistant ready. Type your question or 'exit' to quit.")
fmt.Println()
for {
fmt.Print("Human: ")
if !scanner.Scan() {
break
}
input := strings.TrimSpace(scanner.Text())
if input == "" {
continue
}
if strings.EqualFold(input, "exit") {
fmt.Println("Bye.")
break
}
answer, err := talkToAgent(input)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
continue
}
fmt.Printf("Agent: %s\n\n", answer)
}
}The main loop reads from standard input using bufio.Scanner,
exactly as in the rest of this series. Empty inputs are skipped, and typing exit terminates the application cleanly.
The one difference from the OpenAI and Anthropic versions is what gets passed to talkToAgent: since there is no
package-level history to append to first, the raw input string is passed straight through as an argument. At this
stage talkToAgent is a stub that returns nothing — we fill it in next.
func talkToAgent(input string) (string, error) {
resp, err := chat.SendMessage(context.Background(), genai.Part{
Text: input,
})
if err != nil {
return "", fmt.Errorf("API call failed: %w", err)
}
return resp.Text(), nil
}chat.SendMessage does double duty here: it sends the new user turn to the model, and because chat is a stateful
*genai.Chat object, it also appends both the request and the response to the chat’s internal history automatically.
There is no equivalent of ToParam() or manually appending to a slice — the Go SDK’s Chats helper takes care of it.
resp.Text() is a convenience method that concatenates every text part of the response into a single string, which is
enough for a conversational agent that only expects text back.
AI assistant ready. Type your question or 'exit' to quit.
Human: hi
Agent: Hello! How can I help you today?
Human: what is my name?
Agent: I don't know your name yet, as you haven't told me! What would you like me to call you?
Human: my name is Marko
Agent: Nice to meet you, Marko! How can I help you today?
Human: what is my name?
Agent: Your name is Marko!The key thing to notice in this exchange is that the agent remembers the user’s name, without a single explicit append
anywhere in talkToAgent. That is the Chats helper doing its job: every call to chat.SendMessage extends the same
underlying conversation, so by the time we ask for the name a second time, the model already has it in context.
First tool integration#
Even with a fully managed chat history, the model has a fundamental limitation: it cannot access real-time information. Ask it for the current time, and it will tell you so.
Human: what is the current time in Frankfurt?
Agent: I don't have access to real-time information, so I can't tell you the exact current time in Frankfurt.
However, Frankfurt is in the **Central European Time zone**:
* **CET (UTC+1)** during standard time (late October to late March)
* **CEST (UTC+2)** during Daylight Saving Time (late March to late October)
You can check a world clock or search online for the live local time!This is where tools come in. In the Gemini API, a tool is described as a FunctionDeclaration — a name, a description,
and a parameter schema — grouped inside a Tool and passed to the model alongside the conversation. When the model
decides it needs information it cannot generate from its weights alone, it responds with one or more function calls
instead of, or alongside, text. Your application executes those calls and sends the results back as function responses;
the model incorporates them into its next reply. As with every provider in this series, the model never executes code
itself — it only requests calls and reads results.
The integration process has a clear shape: declare the available tools when creating the chat, check whether the response contains function calls, execute each one, send the results back as function responses, and repeat until the model returns a plain text response with no further calls. This loop is what lets the agent chain multiple tool calls together when a task needs more than one.
The first step is building the function the tool will call.
func getCurrentTime(timezone string) (string, error) {
location, err := time.LoadLocation(timezone)
if err != nil {
return "", fmt.Errorf("failed to load location: %w", err)
}
now := time.Now().In(location)
return now.Format("2006-01-02 15:04:05"), nil
}This is a plain Go function with no awareness of the LLM, identical in spirit to the one used for OpenAI and Anthropic earlier in this series. It takes a timezone, loads the location, and returns the current time formatted as a string. Keeping tool implementations as ordinary functions is intentional — it keeps them testable and reusable outside the agent context.
With the function in place, we need to describe it to the model and update the request loop.
var getTimeToolDefinition = &genai.Tool{
FunctionDeclarations: []*genai.FunctionDeclaration{
{
Name: "get_time",
Description: "Fetch the current time in a given timezone.",
ParametersJsonSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"timezone": map[string]any{
"type": "string",
"description": "Tne international timezone name, e.g. America/Los_Angeles.",
},
},
"required": []string{"timezone"},
},
},
},
}
func talkToAgent(input string) (string, error) {
resp, err := chat.SendMessage(context.Background(), genai.Part{Text: input})
if err != nil {
return "", fmt.Errorf("API call failed: %w", err)
}
for {
// ...
}
}A FunctionDeclaration describes the function to the model: Name is what the model uses when it requests a call,
Description is what the model reads to decide whether to use the tool at all, and ParametersJsonSchema is a
standard JSON Schema document describing the arguments — the same format used by every provider in this series, just
under a Gemini-specific field name. One or more declarations are grouped into a Tool, which is what actually gets
attached to the chat’s configuration.
The request loop in talkToAgent is now wrapped in a for loop, for the same reason as in the OpenAI and Anthropic
versions: a single user message may require multiple round-trips, since the model can call a tool, receive the result,
and decide to call another tool before producing its final answer.
func callTool(call *genai.FunctionCall) map[string]any {
// ...
}
func talkToAgent(input string) (string, error) {
resp, err := chat.SendMessage(context.Background(), genai.Part{
Text: input,
})
if err != nil {
return "", fmt.Errorf("API call failed: %w", err)
}
for {
calls := resp.FunctionCalls()
if len(calls) == 0 {
return resp.Text(), nil
}
responseParts := make([]genai.Part, 0, len(calls))
for _, call := range calls {
fmt.Printf(" [tool] %s(%v)\n", call.Name, call.Args)
result := callTool(call)
fmt.Printf(" [tool result] %v\n", result)
responseParts = append(responseParts, genai.Part{
FunctionResponse: &genai.FunctionResponse{
ID: call.ID,
Name: call.Name,
Response: result,
},
})
}
resp, err = chat.SendMessage(context.Background(), responseParts...)
if err != nil {
return "", fmt.Errorf("API call failed: %w", err)
}
}
}When the model’s response contains no function calls, resp.FunctionCalls() returns an empty slice and we return the
accumulated text directly. When it does contain calls, we iterate over each one, print it for visibility, execute it
via callTool, and print the result too. Each result is wrapped in a genai.Part carrying a FunctionResponse —
matched back to the original call by ID and Name — and all of them are sent back together in a single follow-up
call to chat.SendMessage. Because chat already holds the prior turns, this follow-up only needs the new function
response parts, not the full conversation again. The loop then repeats until the model is satisfied and returns plain text.
type timeArgs struct {
Timezone string `json:"timezone"`
}
func callTool(call *genai.FunctionCall) map[string]any {
switch call.Name {
case "get_time":
raw, err := json.Marshal(call.Args)
if err != nil {
return map[string]any{"error": "failed to marshal tool arguments: " + err.Error()}
}
var args timeArgs
if err := json.Unmarshal(raw, &args); err != nil {
return map[string]any{"error": "failed to parse tool arguments: " + err.Error()}
}
currentTime, err := getCurrentTime(args.Timezone)
if err != nil {
return map[string]any{"error": err.Error()}
}
return map[string]any{"time": currentTime}
default:
return map[string]any{"error": fmt.Sprintf("unknown tool: %q", call.Name)}
}
}callTool is a router, same as in the OpenAI and Anthropic versions, just returning map[string]any instead of a
JSON string — Gemini’s FunctionResponse.Response field expects a map, not a serialised string, so there is no manual
marshalling on the way out. On the way in, call.Args is already a decoded map[string]any; round-tripping it through
json.Marshal/json.Unmarshal into the typed timeArgs struct is a convenient way to get typed access without
writing a manual type assertion for every field. If the tool execution fails — for example, an invalid timezone — the
error goes back as a map[string]any{"error": ...} payload rather than bubbling up to the caller, so the model can see
what went wrong and adjust. Any unrecognised tool name returns a similar error payload.
func main() {
// ...
chat, err = client.Chats.Create(ctx, "gemini-3.6-flash", &genai.GenerateContentConfig{
SystemInstruction: &genai.Content{
Parts: []*genai.Part{genai.NewPartFromText("You are a helpful time assistant. " +
"When asked about time at particular locations, " +
"always use the get_time tool to get current time in a given timezone. " +
"Never guess the time.")},
},
Tools: []*genai.Tool{getTimeToolDefinition},
}, nil)
if err != nil {
fmt.Fprintf(os.Stderr, "error: failed to create chat: %v\n", err)
os.Exit(1)
}
// ...The last piece is wiring the tool into the chat at creation time, alongside an updated system instruction that tells
the model when to reach for get_time. Both the system instruction and the tool list are set once, on
GenerateContentConfig, when the chat is created — there is no equivalent of re-sending Tools on every individual
call, since the Chats helper remembers the configuration for the whole session.
Time assistant ready. Type your question or 'exit' to quit.
Human: what is the current time in Frankfurt?
[tool] get_time(map[timezone:Europe/Berlin])
[tool result] map[time:2026-08-08 17:13:02]
Agent: The current time in Frankfurt, Germany is 5:13 PM on Saturday, August 8, 2026.The [tool] and [tool result] lines show the model calling get_time with the correct IANA timezone for Frankfurt,
and the raw result coming back before the model turns it into a natural-language answer. As in the earlier articles,
the model resolved “Frankfurt” to Europe/Berlin on its own, based on the tool description and its own world
knowledge. The full source for this example is available at
llm-and-golang-examples.
Conclusion#
The Generate Content API, wrapped by the Go SDK’s Chats helper, is the right starting point for building LLM-powered
agents with Gemini in Go — it keeps the request/response model explicit while still handling conversation history for
you, without the overhead of managing server-side interaction state. Adding tools follows the same shape as every other
provider in this series: declare the function, check for calls, execute them, send results back, repeat. The
Interactions API is worth revisiting once background execution or server-managed state actually matter for your use
case, but for a single conversational agent, the simpler API is enough. With this article, all three major providers
covered by this series — OpenAI, Anthropic, and Gemini — now have a working baseline agent to build on.




