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:
- Persistent memory across sessions
- 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.
In the first article, we had:
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.
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?
- The model decided it needed two tools.
- It emitted two tool calls in the same response:
- The Go runtime executed both.
- Their results were appended as tool messages.
- 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.