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:
| 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 |
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.




