Skip to main content
  1. Articles/

LLM and Go: Gemini Integration via Generate Content API

·2874 words·14 mins· loading · loading ·
Marko Milojevic
Author
Marko Milojevic
Software engineer and architect. Golang and LLM enthusiast. Awful chess player, gym rat, harmonica newbie and cat lover.
LLM and Go - This article is part of a series.
Part 6: This Article

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.

FeatureGenerate Content APIInteractions API
Conversation stateClient-managed, or via the Chats helperServer-managed via previous_interaction_id
History managementManual — resent with every request, unless using ChatsAutomatic, referenced by ID
Tool supportManual function callingUnified tool and agent invocation
Background executionNoYes
Data retentionNone — nothing stored server-side55 days (paid), 1 day (free), unless store=false
ControlFullReduced
Best forCustom agents, full control, simplicityLong-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.

Useful Resources
#

LLM and Go - This article is part of a series.
Part 6: This Article

Related

LLM and Go: Anthropic Integration via Messages API

·2757 words·13 mins· loading · loading
The previous two articles in this series covered OpenAI’s side of this problem: Chat Completions, where the client owns the entire conversation history, and Responses, where OpenAI’s servers do. Anthropic’s Claude models are built on a third set of API decisions — close enough to Chat Completions in shape that the same agent design carries over almost directly, but different enough in the details, like how the system prompt is passed and how a response is structured, that it is worth building the same agent again to see exactly where. This article rebuilds that agent on Anthropic’s Messages API — the stateless, request-based interface behind Claude. By the end, you will have a working conversational agent that can call external tools to answer questions it otherwise could not, and a clear picture of what changes when you swap providers. A short introduction to Claude and Anthropic # 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. Claude is Anthropic’s model family, and the company itself was founded in 2021 by a group of former OpenAI researchers, including Dario Amodei and Daniela Amodei, with AI safety as its founding focus. What differentiates Claude’s training pipeline from a pure RLHF approach is Constitutional AI — a technique where the model critiques and revises its own outputs against a written set of principles, rather than relying solely on human raters to judge every response. The goal is a model that behaves predictably even in situations no human rater explicitly labelled. For developers, the way to reach any Claude model programmatically is the Messages API — the stateless, request-based interface used throughout this article.

LLM and Go: OpenAI Integration via Chat Completions API

·2721 words·13 mins· loading · loading
For most of my career, integrating external intelligence into an application meant calling a rules engine, training a custom classifier, or encoding business logic that someone had painfully documented in a spreadsheet. The idea that I could describe a task in plain language and have a model respond with genuine reasoning was not something I expected to become production-ready in my working life. Then GPT happened, and it changed what backend developers need to know. This article is the first in a series on using LLMs in Go. We start with the OpenAI Chat Completions API — the stateless, request-based interface that gives you direct control over every aspect of the conversation. By the end, you will have a working conversational agent that can call external tools to answer questions it otherwise could not. A short introduction to ChatGPT and OpenAI # 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. ChatGPT is OpenAI’s conversational product built on the GPT model series. What set it apart from raw GPT-3 was the addition of reinforcement learning from human feedback (RLHF) — a technique that fine-tunes the base model to follow instructions and produce responses that human raters judge as helpful and safe. When ChatGPT launched in late 2022, it became one of the fastest-adopted consumer products in history. For developers, the more relevant artefact is the API behind it — specifically the Chat Completions API, which gives programmatic access to the same models powering the product.

LLM and Go: Investigating Anthropic Messages API

·2276 words·11 mins· loading · loading
In the previous article I covered the fundamentals of Anthropic’s Messages API: setting up a client, maintaining conversation history, and integrating tools. That was enough to build a working conversational agent. This article goes a level deeper — into the API parameters that shape what the model returns and how it thinks. Two parameters stand out as particularly useful in production: output_config.format and output_config.effort. The first gives you control over the structure of the model’s output. The second controls how much the model reasons before responding — which turns out to matter more than you might expect once you start caring about latency and cost. Messages API details # The Messages API endpoint accepts a rich set of parameters. Most have sensible defaults and you will rarely touch them, but understanding what is available saves you from reaching for workarounds that already exist in the API. The table below covers a selection of the current parameters from the API reference: Parameter Type Description model string ID of the model to use messages array Conversation history as an ordered list of messages system string/array System prompt that sets the model’s behaviour, kept separate from messages max_tokens integer Maximum tokens the model may generate — required on every request output_config.format object Constrains the response to a JSON Schema output_config.effort string Reasoning depth: low, medium, high, xhigh, max thinking object Enables and configures extended or adaptive thinking temperature number Sampling temperature from 0 to 1; higher values produce more random output top_p number Alternative to temperature; nucleus sampling probability mass top_k integer Restricts sampling to the top K most likely tokens stop_sequences array Custom sequences at which the API stops generating stream boolean Stream partial responses as server-sent events tools array List of tools the model may call tool_choice object Controls which tool the model calls metadata object Arbitrary metadata about the request, such as an end-user ID In this article we focus on output_config.format and output_config.effort — two parameters with a direct, visible impact on production systems. Information extraction with output_config.format # The format field inside output_config controls how the model structures its output. By default, Claude replies with plain text. Setting output_config.format to a json_schema document constrains the response to conform to that schema — Anthropic calls this structured outputs.