Contents

Building a stateful multi-tool agent in Go

In the previous article, we built a minimal agent:

  • One tool
  • In-memory conversation
  • A clean reasoning loop

Now we’re going to level it up.

This version introduces two major upgrades:

  1. Persistent memory across sessions
  2. Multiple tools with different capabilities

We are still using go-openai as our client library, compatible with OpenAI and ollama.

But now the agent feels much closer to something you’d actually deploy.

What’s new in this version?

Compared to the previous article, this main.go adds:

  • 🧠 Session-based persistent memory (memory.json)
  • 🧮 A Python execution tool
  • 🌍 A live HTTP tool (random quote API)
  • 🧰 A multi-tool orchestration layer

Let’s break it down in structured chunks, and as before, if you copy all chunks in order, you’ll get the full working file.

Persistent memory: Sessions that survive restarts

In the first article, memory lived only inside the messages slice.

Once the program stopped, everything was gone.

Now we introduce:

1
memoryFile := "memory.json"

And this logic:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
var messages []openai.ChatCompletionMessage
sessionName := os.Getenv("SESSION_NAME")

if sessionName != "" {
	fmt.Printf("Loading conversation for session '%s'...\n", sessionName)
	messages, _ = loadConversation(sessionName, memoryFile)
} else {
	fmt.Println("No session name provided. Starting new conversation with empty history.")
	sessionName = "default"
	messages = []openai.ChatCompletionMessage{
		{
			Role: openai.ChatMessageRoleSystem,
			Content: `You are an assistant that can only help the user by using the available tools provided to you.`,
		},
	}
}

What’s happening?

  • If SESSION_NAME is set → load past conversation.
  • If not → start fresh.
  • Conversations are stored in a shared JSON file.

This means:

1
export SESSION_NAME=myproject

Now your agent remembers everything from previous runs.

This is the first step toward real conversational state.

Saving and loading conversations

Here’s how persistence works.

Saving

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func saveConversation(messages []openai.ChatCompletionMessage, sessionName, memoryFile string) error {
	sessions := map[string][]openai.ChatCompletionMessage{}

	if data, err := os.ReadFile(memoryFile); err == nil {
		_ = json.Unmarshal(data, &sessions)
	}

	sessions[sessionName] = messages

	data, err := json.MarshalIndent(sessions, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile(memoryFile, data, 0644)
}

We:

  • Load the file (if it exists)
  • Update the current session
  • Write everything back

Simple key-value persistence.

Loading

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
func loadConversation(sessionName, memoryFile string) ([]openai.ChatCompletionMessage, error) {
	sessions := map[string][]openai.ChatCompletionMessage{}

	data, err := os.ReadFile(memoryFile)
	if err != nil {
		return nil, err
	}

	err = json.Unmarshal(data, &sessions)
	if err != nil {
		return nil, err
	}

	messages, ok := sessions[sessionName]
	if !ok {
		return nil, fmt.Errorf("session '%s' not found", sessionName)
	}

	return messages, nil
}

Now your agent has durable memory.

Not vector-based. Not semantic. Just raw conversation history.

And that’s perfectly fine for many use cases.

Multiple tools, expanding capability

In the first article, we had:

1
get_time()

Now we have three tools:

1
2
3
4
5
6
7
func buildTools() []openai.Tool {
	return []openai.Tool{
		// 1. get_time
		// 2. run_python_code
		// 3. get_random_quote
	}
}

Let’s break them down.

get_time

Same as before:

  • No parameters
  • Returns UTC time

run_python_code

This one is powerful.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
{
	Type: openai.ToolTypeFunction,
	Function: &openai.FunctionDefinition{
		Name:        "run_python_code",
		Description: "Use this tool to solve calculations, manipulate data, or perform any other Python-related tasks.",
		Parameters: map[string]any{
			"type": "object",
			"properties": map[string]any{
				"code": map[string]any{
					"type": "string",
				},
			},
			"required": []string{"code"},
		},
	},
}

This allows the model to:

  • Execute math
  • Manipulate strings
  • Perform structured data operations
  • Use Python’s ecosystem

Execution happens here:

1
2
cmd := exec.Command("python3", "-c", args.Code)
output, err := cmd.CombinedOutput()

⚠️ Important note for readers: This runs arbitrary code. In production, you must sandbox this.

But for learning? It’s perfect.

get_random_quote

This tool performs a real HTTP request:

1
resp, err := http.Get("https://zenquotes.io/api/random")

The model now has:

  • Live internet capability (limited)
  • External data fetching
  • JSON parsing via Go

We’ve moved from toy agent to something real.

Tool execution router

All tools are handled centrally:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
func handleToolCall(tc openai.ToolCall) string {
	switch tc.Function.Name {

	case "get_time":
		...

	case "run_python_code":
		...

	case "get_random_quote":
		...

	default:
		return "unknown tool"
	}
}

This pattern scales cleanly:

  • Add a tool definition
  • Add a case here
  • Done

No magic. Just routing.

The agent loop (still the same core idea)

The heart of the agent hasn’t changed:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
for {
	resp, err := client.CreateChatCompletion(...)
	msg := resp.Choices[0].Message
	messages = append(messages, msg)

	if len(msg.ToolCalls) > 0 {
		// execute tools
		continue
	}

	fmt.Println(msg.Content)
	break
}

What changed?

  • Now multiple tools may be called.
  • The agent may chain tool calls.
  • Memory persists between runs.

The architecture remains:

Model → Decide → Execute tool → Append result → Repeat

Exactly what we defined in the first article.

Full code

Here’s the complete main.go with all the pieces together.

Click to expand
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
package main

import (
	"bufio"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"os/exec"
	"strings"
	"time"

	openai "github.com/sashabaranov/go-openai"
)

func main() {
	ctx := context.Background()
	memoryFile := "memory.json"

	apiKey := os.Getenv("OPENAI_API_KEY")
	baseURL := os.Getenv("OPENAI_BASE_URL") // e.g. http://localhost:11434/v1 (Ollama)

	config := openai.DefaultConfig(apiKey)

	if baseURL != "" {
		config.BaseURL = baseURL
	}

	client := openai.NewClientWithConfig(config)

	model := os.Getenv("MODEL")
	if model == "" {
		// OpenAI: gpt-4o-mini
		// Ollama: llama3
		model = "gpt-4o-mini"
	}

	var messages []openai.ChatCompletionMessage
	sessionName := os.Getenv("SESSION_NAME")
	if sessionName != "" {
		fmt.Printf("Loading conversation for session '%s'...\n", sessionName)
		messages, _ = loadConversation(sessionName, memoryFile)
	} else {
		fmt.Println("No session name provided. Starting new conversation with empty history.")
		sessionName = "default"
		messages = []openai.ChatCompletionMessage{
			{
				Role:    openai.ChatMessageRoleSystem,
				Content: `You are an assistant that can only help the user by using the available tools provided to you.`,
			},
		}
	}

	tools := buildTools()

	reader := bufio.NewReader(os.Stdin)

	fmt.Println("Simple Go Agent (type 'exit' to quit)")
	for {
		fmt.Print("\n> ")
		input, _ := reader.ReadString('\n')
		input = strings.TrimSpace(input)

		if input == "exit" {
			return
		}

		messages = append(messages, openai.ChatCompletionMessage{
			Role:    openai.ChatMessageRoleUser,
			Content: input,
		})

		for {
			resp, err := client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
				Model:    model,
				Messages: messages,
				Tools:    tools,
			})
			if err != nil {
				log.Fatal(err)
			}

			msg := resp.Choices[0].Message
			messages = append(messages, msg)

			// If model calls a tool, execute it
			if len(msg.ToolCalls) > 0 {
				for _, toolCall := range msg.ToolCalls {
					result := handleToolCall(toolCall)

					messages = append(messages, openai.ChatCompletionMessage{
						Role:       openai.ChatMessageRoleTool,
						Content:    result,
						ToolCallID: toolCall.ID,
					})
				}
				continue
			}

			fmt.Println("\nAssistant:", msg.Content)
			break
		}

		saveConversation(messages, sessionName, memoryFile)
	}
}

// saveConversation saves the conversation history for a session name in a shared memory file (JSON map)
func saveConversation(messages []openai.ChatCompletionMessage, sessionName, memoryFile string) error {
	sessions := map[string][]openai.ChatCompletionMessage{}
	// Load existing sessions if file exists
	if data, err := os.ReadFile(memoryFile); err == nil {
		_ = json.Unmarshal(data, &sessions)
	}

	sessions[sessionName] = messages

	data, err := json.MarshalIndent(sessions, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile(memoryFile, data, 0644)
}

// loadConversation loads the conversation history for a session name from the shared memory file (JSON map)
func loadConversation(sessionName, memoryFile string) ([]openai.ChatCompletionMessage, error) {
	sessions := map[string][]openai.ChatCompletionMessage{}
	data, err := os.ReadFile(memoryFile)
	if err != nil {
		return nil, err
	}
	err = json.Unmarshal(data, &sessions)
	if err != nil {
		return nil, err
	}
	messages, ok := sessions[sessionName]
	if !ok {
		return nil, fmt.Errorf("session '%s' not found", sessionName)
	}
	return messages, nil
}

func buildTools() []openai.Tool {
	return []openai.Tool{
		{
			Type: openai.ToolTypeFunction,
			Function: &openai.FunctionDefinition{
				Name:        "get_time",
				Description: "Returns the current server time",
				Parameters: map[string]any{
					"type":       "object",
					"properties": map[string]any{},
				},
			},
		},
		{
			Type: openai.ToolTypeFunction,
			Function: &openai.FunctionDefinition{
				Name:        "run_python_code",
				Description: "Use this tool to solve calculations, manipulate data, or perform any other Python-related tasks. The code should use print() to print the final result to stdout.",
				Parameters: map[string]any{
					"type": "object",
					"properties": map[string]any{
						"code": map[string]any{
							"type":        "string",
							"description": "Python code that uses print() to print the final result to stdout.",
						},
					},
					"required": []string{"code"},
				},
			},
		},
		{
			Type: openai.ToolTypeFunction,
			Function: &openai.FunctionDefinition{
				Name:        "get_random_quote",
				Description: "Fetches a random inspirational quote from the web.",
				Parameters: map[string]any{
					"type":       "object",
					"properties": map[string]any{},
				},
			},
		},
	}
}

func handleToolCall(tc openai.ToolCall) string {
	switch tc.Function.Name {

	case "get_time":
		fmt.Println("Calling get_time...")
		return fmt.Sprintf("Current server time: %s", time.Now().UTC().Format(time.RFC3339))

	case "run_python_code":
		fmt.Println("Calling run_python_code...")
		out, err := runPython(tc.Function.Arguments)
		if err != nil {
			return "Python error: " + err.Error() + "\nOutput: " + out
		}
		return out

	case "get_random_quote":
		fmt.Println("Calling get_random_quote...")
		quote, err := fetchRandomQuote()
		if err != nil {
			return "Failed to fetch quote: " + err.Error()
		}
		return quote

	default:
		return "unknown tool"
	}
}

// fetchRandomQuote fetches a random quote from zenquotes.io
func fetchRandomQuote() (string, error) {
	resp, err := http.Get("https://zenquotes.io/api/random")
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return "", err
	}
	// Response is a JSON array with one object: [{"q":"...","a":"..."}]
	var data []struct {
		Q string `json:"q"`
		A string `json:"a"`
	}
	err = json.Unmarshal(body, &data)
	if err != nil || len(data) == 0 {
		return "", fmt.Errorf("invalid response from quote API")
	}
	return fmt.Sprintf("%s — %s", data[0].Q, data[0].A), nil
}

// runPython executes the provided Python code and returns the output or error.
func runPython(arguments string) (string, error) {
	// Arguments are a JSON string
	var args struct {
		Code string `json:"code"`
	}

	err := json.Unmarshal([]byte(arguments), &args)
	if err != nil {
		return "", fmt.Errorf("Invalid arguments: %s", err.Error())
	}

	if args.Code == "" {
		return "", fmt.Errorf("Invalid arguments: missing 'code' string.")
	}

	cmd := exec.Command("python3", "-c", args.Code)
	output, err := cmd.CombinedOutput()

	return string(output), err
}

Seeing the agent in action

Now that we have the agent running, let’s take a look at an actual execution round. This helps connect the theory and code from the previous chapters to a tangible experience.

When we start the program, we see a simple CLI prompt using ollama and the ministral-3:3b model with tools enabled:

1
$ OPENAI_BASE_URL=http://localhost:11434/v1 MODEL=ministral-3:3b SESSION=session1 go run main.go 

The agent remembers everything inside memory.json, including tool calls and intermediate results.

Below is the reconstructed shell interaction derived directly from the stored message array.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
Simple Go Agent (type 'exit' to quit)

> get a random quote and join it with the current server time.
Calling get_random_quote...
Calling get_time...

Assistant:
Here is your random quote combined with the current server time:

---
"If you want something, then you lose everything. If you don't want anything, then you already have everything." — Seungsahn
Time: February 28, 2026 at 14:56:52 (UTC)
---

What just happened?

  1. The model decided it needed two tools.
  2. It emitted two tool calls in the same response:
    • get_random_quote
    • get_time
  3. The Go runtime executed both.
  4. Their results were appended as tool messages.
  5. The model received both outputs and composed the final response.

This is already more advanced than the single-tool example from the previous article.

The agent orchestrated multiple capabilities in one reasoning cycle.

Now it gets interesting

1
2
3
4
5
6
7
> now convert it to base64
Calling run_python_code...

Assistant:
The quote and time have been converted to base64 format:

IklmIHlvdSB3YW50IHNvbWV0aGluZywgdGhlbiB5b3UgbG9zZSBldmVyeXRoaW5nLiBJZiB5b3UgZG9uJ3Qgd2FudCBhbnl0aGluZywgdGhlbiB5b3UgYWxyZWFkeSBoYXZlIGV2ZXJ5dGhpbmcuIiDigJQgU2V1bmdzYWhuClRpbWU6IEZlYnJ1YXJ5IDI4LCAyMDI2IGF0IDE0OjU2OjUyIChVVEMp

Here the agent:

  • Reused conversation memory.
  • Extracted the previous output.
  • Generated Python code dynamically.
  • Called run_python_code.
  • Returned the encoded string.

No hardcoded logic. The model wrote the Python snippet itself.

One more step

1
2
3
4
5
6
7
> awesome! Can you give me the md5 of that?
Calling run_python_code...

Assistant:
The MD5 hash of the base64 string is:

966fc7d1baa87cd8caedc8884968800b

What’s important here:

  • The model understood that hashing is computational.
  • It chose the Python tool again.
  • It generated new code using hashlib.
  • It returned the final result.

What we’ve achieved

With just this file, we now have:

  • ✅ Multi-tool orchestration
  • ✅ Persistent session memory
  • ✅ Python execution capability
  • ✅ Live HTTP integration
  • ✅ Cloud/local model compatibility

This is no longer just a demo.

It’s the skeleton of a production-capable AI backend.

What's next?
In the next article, we’ll make it more powerful by adding a multi-agent approach.