Skip to main content
  1. Articles/

LLM and Go: Gemini integration via Interactions API

·2880 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 7: This Article

The previous article in this series covered the Generate Content API — how to set up a client, let the Chats helper manage conversation history, and call external tools. This article covers the other Gemini interface: the Interactions API.

The Interactions API moves conversation state from the client to Google’s servers. You no longer maintain a chat object and let a helper accumulate history locally; instead, you track an interaction ID and pass it back on the next request. That is a meaningful shift for agent-oriented applications — less client-side bookkeeping, but also less transparency into exactly what the server reconstructs on each turn. Understanding the trade-offs between the two APIs is worth doing before choosing which one to build on.

Interactions API
#

Google made the Interactions API generally available recently, positioning it as the unified way to call both plain Gemini models and its specialised agents through a single endpoint. The Generate Content API is stateless by default — every request must carry the full conversation history, whether you build that history yourself or let the Chats helper do it for you locally. The Interactions API inverts this: conversation state lives on Google’s servers, and you reference previous turns by ID rather than resending them.

Both APIs give you access to the same underlying models and tool-calling mechanics. The difference is where the orchestration responsibility sits. The table below, first introduced in the Generate Content API article, summarizes the trade-offs:

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

The Generate Content API is the right default when you want to control exactly what the model sees and keep the request/response model explicit. The Interactions API reduces boilerplate and fits well when background execution or server-managed state actually matter for what you are building. In this article we build the same conversational agent we built before — but with the Interactions API driving state management.

First AI agent
#

Unlike Generate Content, there is no official Go SDK for the Interactions API yet — the genai package used in the previous article does not cover it. That means talking to it means talking directly to the REST endpoint over net/http, building the JSON request and response types ourselves.

Client setup

package main

import (
	"bufio"

	// ...
)

const interactionsURL = "https://generativelanguage.googleapis.com/v1beta/interactions"

var apiKey string
var httpClient = &http.Client{}
var previousInteractionID string

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)
	}

	scanner := bufio.NewScanner(os.Stdin)
	fmt.Println("AI assistant ready. Type your question or 'exit' to quit.")
	fmt.Println()
	
	// ...
}

There is no client object to construct here, since there is no SDK wrapping this API yet — just the API key, read from the environment as usual, and a shared http.Client we reuse across requests. interactionsURL is the REST endpoint we will POST every request to. The difference that matters most is previousInteractionID: instead of a history slice or a stateful chat object, all we track is the ID of the last interaction, so we can tell the API what the previous turn was. When it is empty, the API treats the request as the start of a new conversation.

Main loop

func talkToAgent(userInput 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 loop itself is identical to the Generate Content version: bufio.Scanner reads standard input line by line, empty input is skipped, and exit terminates the application. Each non-empty line is passed straight to talkToAgent as a plain string argument — there is no history to append to first, since the Interactions API owns that state on its side. At this stage talkToAgent is still a stub; we fill it in next.

Sending an interaction

type interactionRequest struct {
	Model                 string         `json:"model"`
	Input                 any            `json:"input"`
	SystemInstruction     string         `json:"system_instruction,omitempty"`
	Tools                 []functionTool `json:"tools,omitempty"`
	PreviousInteractionID string         `json:"previous_interaction_id,omitempty"`
}

type stepContent struct {
	Type string `json:"type"`
	Text string `json:"text"`
}

type interactionStep struct {
	Type      string          `json:"type"`
	ID        string          `json:"id,omitempty"`
	Name      string          `json:"name,omitempty"`
	Arguments json.RawMessage `json:"arguments,omitempty"`
	Content   []stepContent   `json:"content,omitempty"`
}

type interactionResponse struct {
	ID     string            `json:"id"`
	Status string            `json:"status,omitempty"`
	Steps  []interactionStep `json:"steps"`
}

func talkToAgent(userInput string) (string, error) {
	body, err := json.Marshal(interactionRequest{
		Model:                 "gemini-3.6-flash",
		Input:                 userInput,
		SystemInstruction:     "You are a helpful assistant.",
		PreviousInteractionID: previousInteractionID,
	})
	if err != nil {
		return "", fmt.Errorf("failed to marshal request: %w", err)
	}

	httpReq, err := http.NewRequestWithContext(context.Background(), http.MethodPost, interactionsURL, bytes.NewReader(body))
	if err != nil {
		return "", fmt.Errorf("failed to build request: %w", err)
	}
	httpReq.Header.Set("Content-Type", "application/json")
	httpReq.Header.Set("x-goog-api-key", apiKey)

	resp, err := httpClient.Do(httpReq)
	if err != nil {
		return "", fmt.Errorf("API call failed: %w", err)
	}
	defer resp.Body.Close()

	data, err := io.ReadAll(resp.Body)
	if err != nil {
		return "", fmt.Errorf("failed to read response body: %w", err)
	}
	if resp.StatusCode != http.StatusOK {
		return "", fmt.Errorf("API call failed with status %d: %s", resp.StatusCode, string(data))
	}

	var out interactionResponse
	if err := json.Unmarshal(data, &out); err != nil {
		return "", fmt.Errorf("failed to parse response: %w", err)
	}

	previousInteractionID = out.ID

	for _, step := range out.Steps {
		if step.Type != "model_output" && step.Type != "message" {
			continue
		}
		for _, content := range step.Content {
			if content.Type == "text" {
				return content.Text, nil
			}
		}
	}

	return "", fmt.Errorf("unexpected response: no text output")
}

interactionRequest, interactionStep, and interactionResponse are our own types — there is no SDK to generate them for us, so they mirror the JSON shape of the REST API directly. Input is declared as any rather than a plain string, because later, once tools are involved, a follow-up turn needs to send a structured tool-result object instead of plain text; a single field has to hold either shape. Tools and the extra fields on interactionStep are not used yet in this first version — they exist because the same types are reused, unchanged, once tool calling is introduced later.

Building the request body starts with json.Marshal of an interactionRequest: the model name, the user’s input, a system instruction, and — if we have one — the previous interaction ID that ties this request to the ongoing conversation. The request is a plain POST to interactionsURL, authenticated with the x-goog-api-key header rather than a bearer token. After a successful call, we immediately store out.ID in previousInteractionID; that one assignment is the entirety of our state management. The response is a list of steps, and we walk them looking for a model_output or message step containing a text content block — that is where the model’s reply lives.

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! 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 agent remembers the user’s name across turns, exactly as it did with the Generate Content API — but this time without a history slice or a chat object anywhere in our code. previousInteractionID links each request to the prior one, and Google reconstructs the conversation on its side.

First tool integration
#

The Interactions API supports the same tool-calling pattern as Generate Content. To demonstrate, consider what happens when you ask the agent a question it cannot answer without real-world data:

Human: what is the current time in Frankfurt?
Agent: I don't have access to real-time information or a live clock, so I can't give you the exact current time. 

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 easily check the current time by searching "time in Frankfurt" on Google or checking a world clock app!

The model knows about time zones — it just does not know what time it is right now. Tools exist precisely to fill this gap: they let the model delegate specific calls to external functions and incorporate the results into its response. The process works in three steps. First, you send the model a list of tool definitions — names, descriptions, and parameter schemas. The model reads those definitions and decides whether it needs to invoke one before it can answer. If it does, the response comes back with a function_call step instead of a text message, naming the tool and the arguments it wants to pass. You execute that call on your side, then send a new request with the result. The model may request additional tool calls or produce a final answer. This loop continues until the model has everything it needs.

The first step is a plain Go function that retrieves the current time in a given timezone — identical in spirit to the one used for every other provider in this series:

const systemPrompt = "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."

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
}

Here time.LoadLocation resolves an IANA timezone name like Europe/Berlin to a *time.Location, and time.Now().In(location) gives us the current instant in that timezone. Alongside it, systemPrompt is pulled out into a package-level constant — it is about to be reused across every request in the tool loop, including the follow-up calls that carry a tool result instead of the original question.

Next, we define the tool and update talkToAgent to include it in the request:

const model = "gemini-3.6-flash"

type functionTool struct {
	Type        string         `json:"type"`
	Name        string         `json:"name"`
	Description string         `json:"description"`
	Parameters  map[string]any `json:"parameters"`
}

type functionResultInput struct {
	Type   string `json:"type"`
	CallID string `json:"call_id"`
	Name   string `json:"name"`
	Result string `json:"result"`
}

var getTimeToolDefinition = functionTool{
	Type:        "function",
	Name:        "get_time",
	Description: "Fetch the current time in a given timezone.",
	Parameters: 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 createInteraction(req interactionRequest) (*interactionResponse, error) {
	body, err := json.Marshal(req)
	if err != nil {
		return nil, fmt.Errorf("failed to marshal request: %w", err)
	}

	httpReq, err := http.NewRequestWithContext(context.Background(), http.MethodPost, interactionsURL, bytes.NewReader(body))
	if err != nil {
		return nil, fmt.Errorf("failed to build request: %w", err)
	}
	httpReq.Header.Set("Content-Type", "application/json")
	httpReq.Header.Set("x-goog-api-key", apiKey)

	resp, err := httpClient.Do(httpReq)
	if err != nil {
		return nil, fmt.Errorf("API call failed: %w", err)
	}
	defer resp.Body.Close()

	data, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("failed to read response body: %w", err)
	}

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("API call failed with status %d: %s", resp.StatusCode, string(data))
	}

	var out interactionResponse
	if err := json.Unmarshal(data, &out); err != nil {
		return nil, fmt.Errorf("failed to parse response: %w", err)
	}

	return &out, nil
}

func talkToAgent(userInput string) (string, error) {
	req := interactionRequest{
		Model:                 model,
		Input:                 userInput,
		SystemInstruction:     systemPrompt,
		Tools:                 []functionTool{getTimeToolDefinition},
		PreviousInteractionID: previousInteractionID,
	}

	for {
	    resp, err := createInteraction(req)
		if err != nil {
			return "", err
		}
		
		previousInteractionID = resp.ID
		
		// ...
	}
}

functionTool describes the function to the model — Name is what the model uses when it requests a call, Description is what it reads to decide whether to use the tool at all, and Parameters is a standard JSON Schema object declaring a single required timezone string. functionResultInput is the other new type here: it is the shape Input takes on a follow-up request, once we have a tool result to send back rather than a fresh question.

Since a tool round-trip means calling the API more than once per turn, it is worth pulling the raw HTTP mechanics out of talkToAgent and into their own function. createInteraction takes a request, does the marshal-send-parse dance we already saw in the first version of talkToAgent, and returns a parsed *interactionResponse — everything the loop needs to decide what to do next. talkToAgent itself is now wrapped in a for loop for the same reason as in the Generate Content and OpenAI Responses versions: a single user message may require multiple round-trips before the model has everything it needs to answer.

func callTool(name string, arguments json.RawMessage) string {
	// ...
}

func talkToAgent(userInput string) (string, error) {
	// ...

	for {
		resp, err := createInteraction(req)
		if err != nil {
			return "", err
		}

		previousInteractionID = resp.ID

		if resp.Status == "requires_action" {
			var pending *interactionStep
			for _, step := range resp.Steps {
				if step.Type == "function_call" {
					pending = &step
					break
				}
			}
			if pending == nil {
				return "", fmt.Errorf("unexpected response: status requires_action with no function_call step")
			}

			fmt.Printf("  [tool] %s(%s)\n", pending.Name, string(pending.Arguments))

			result := callTool(pending.Name, pending.Arguments)

			fmt.Printf("  [tool result] %s\n", result)

			req = interactionRequest{
				Model: model,
				Input: functionResultInput{
					Type:   "function_result",
					CallID: pending.ID,
					Name:   pending.Name,
					Result: result,
				},
				SystemInstruction:     systemPrompt,
				Tools:                 []functionTool{getTimeToolDefinition},
				PreviousInteractionID: previousInteractionID,
			}
			continue
		}

		for _, step := range resp.Steps {
			if step.Type != "model_output" && step.Type != "message" {
				continue
			}
			for _, content := range step.Content {
				if content.Type == "text" {
					return content.Text, nil
				}
			}
		}

		return "", fmt.Errorf("unexpected response: no text output, status %q", resp.Status)
	}
}

A status of requires_action is how the Interactions API signals that it is waiting on a tool result before it can continue. When that happens, we scan the response steps for a function_call step, print it for visibility, execute it via callTool, and print the result too. The next request reuses the same interactionRequest shape, but with Input set to a functionResultInput instead of a plain string — CallID and Name tie the result back to the call the model made, and PreviousInteractionID keeps it anchored to the same conversation. continue sends us straight back to the top of the loop with that new request. Once a response comes back without requires_action, we fall through to the same text-extraction logic as the first version of talkToAgent.

type timeArgs struct {
	Timezone string `json:"timezone"`
}

func callTool(name string, arguments json.RawMessage) string {
	switch name {
	case "get_time":
		var args timeArgs
		if err := json.Unmarshal(arguments, &args); err != nil {
			errMsg, _ := json.Marshal(map[string]string{"error": "failed to parse tool arguments: " + err.Error()})
			return string(errMsg)
		}

		currentTime, err := getCurrentTime(args.Timezone)
		if err != nil {
			errMsg, _ := json.Marshal(map[string]string{"error": err.Error()})
			return string(errMsg)
		}

		out, _ := json.Marshal(map[string]string{"time": currentTime})
		return string(out)

	default:
		errMsg, _ := json.Marshal(map[string]string{"error": fmt.Sprintf("unknown tool: %q", name)})
		return string(errMsg)
	}
}

callTool is a dispatcher, same shape as every other provider in this series: it switches on the tool name and routes to the appropriate Go function. arguments arrives as json.RawMessage, so it unmarshals directly into the typed timeArgs struct without an intermediate re-marshal step. The result — and any error — is returned as a JSON string, which is what functionResultInput.Result expects. Errors from getCurrentTime are returned as JSON rather than propagated as Go errors, so the model can read what went wrong and respond to the user intelligently instead of the whole loop crashing.

Time assistant ready. Type your question or 'exit' to quit.

Human: what is the time in Frankfurt?
  [tool] get_time({"timezone":"Europe/Berlin"})
  [tool result] {"time":"2026-08-08 17:57:09"}
Agent: The current time in Frankfurt, Germany is 5:57 PM (17:57) on Saturday, August 8, 2026.

The [tool] and [tool result] lines show the model calling get_time with the correct IANA timezone for Frankfurt, the same as it did through the Generate Content API — the tool-calling mechanics are consistent across both interfaces, only the request and response envelope around them changes. The full source for this example is available at llm-and-golang-examples.

Conclusion
#

The Interactions API trades control for convenience. Server-side state management removes the history-maintenance boilerplate that Generate Content leaves to you, and the tool-calling pattern maps cleanly onto the same declare-execute-respond loop used everywhere else in this series. The cost is the same one every managed-state API carries: without an official Go SDK yet, you are hand-rolling the request and response types against the REST endpoint directly, and you take on Google’s data-retention window instead of keeping everything local. For most single-agent Go services, the Generate Content API’s Chats helper remains the simpler, more defensible default. Reach for the Interactions API once background execution or cross-session, server-managed state genuinely matter for what you are building.

Useful Resources
#

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

Related

LLM and Go: Gemini Integration via Generate Content API

·2874 words·14 mins· loading · loading
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.

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.