In the previous article, we built a capable agent with multiple tools with real capabilities (Python, HTTP, time). It worked.
But there’s a problem with that design.
As you add more tools, the agent needs to:
- Track all tool definitions
- Reason about which tool to use
- Handle all execution contexts
- Maintain one giant conversation history
This doesn’t scale well.
In this article, we’re going to refactor it into something much more powerful:
A multi-agent architecture.
Instead of one agent with many tools, we’ll have:
- 🎯 A planner agent that orchestrates work
- 🐍 A Python specialist that handles computation
- ⏰ A time specialist that retrieves server time
- 💬 A quote specialist that fetches inspirational quotes
Each specialist:
- Has one focused capability
- Maintains its own conversation context
- Can be called like a tool by the planner
The planner doesn’t know how to execute Python.
It just knows who to delegate to.
This is the pattern you need when building real agent systems.
Let’s build it.
Why multi-agent architecture matters
Before we dive into code, let’s understand the problem.
The monolithic agent problem
In the previous version, our agent had:
1
2
3
4
5
6
| tools := []openai.Tool{
get_time,
run_python_code,
get_random_quote,
// ... imagine 20 more tools here
}
|
Every request required the model to:
- Parse the user’s intent
- Choose from 20+ tools
- Execute the tool
- Reason about the result
- Decide if it needs another tool
- Repeat
This creates cognitive load on the model.
More tools = more tokens = higher latency = more errors.
The multi-agent solution
Now imagine this instead:
1
2
3
4
5
| planner_tools := []Tool{
python_agent, // delegates to Python specialist
time_agent, // delegates to time specialist
quote_agent, // delegates to quote specialist
}
|
The planner sees 3 high-level operations.
When it calls python_agent:
- The request is routed to a specialist agent
- That agent has only the
run_python_code tool - It executes with full context
- It returns the result
- The planner continues
Each specialist is an expert in one domain.
The planner is an expert in orchestration.
This is:
- More scalable
- More maintainable
- More aligned with how LLMs reason
The mental model
Think of it like a software organization:
- The planner is the project manager
- Each worker agent is a domain expert
- Communication happens via structured messages
- The PM doesn’t write Python — they delegate to the Python expert
Same principle here.
What changed from the previous version
- ✅ Agent abstraction (
agent/agent.go) - ✅ Hierarchical agent system (planner + workers)
- ✅ Per-agent conversation context
- ✅ Agent-as-tool pattern via
AsTool() - ✅ Clean separation of concerns
We removed memory to keep the focus on the architecture.
You can add it back later.
The Agent abstraction
The core of this version is the Agent type.
Let’s look at it.
Structure
1
2
3
4
5
6
7
8
9
| type Agent struct {
Client *openai.Client
Model string
Name string
Description string
tools []openai.Tool
toolHandlers map[string]ToolHandler
}
|
Each agent:
- Has a name and description
- Owns a set of tools
- Maintains tool execution logic
- Can run independently
Creating an agent
1
| func NewAgent(client *openai.Client, model, name, description string) (*Agent, error)
|
Example:
1
2
3
4
5
6
| planner, err := agent.NewAgent(
client,
"gpt-4o-mini",
"planner",
"You are a planning and orchestration agent...",
)
|
The description becomes the system prompt.
This is critical:
- Each agent has its own personality.
- The planner thinks about orchestration.
- The Python worker thinks about computation.
Different roles, different prompts.
1
| func (a *Agent) AddTool(tool Tool) error
|
A tool is:
1
2
3
4
| type Tool struct {
Definition openai.Tool
Handler ToolHandler
}
|
This bundles:
- The OpenAI function definition (schema)
- The actual Go function that executes it
Example:
1
2
3
4
5
6
7
8
9
10
11
| pythonWorker.AddTool(agent.Tool{
Definition: openai.Tool{
Type: openai.ToolTypeFunction,
Function: &openai.FunctionDefinition{
Name: "run_python_code",
Description: "Executes Python code...",
Parameters: ...,
},
},
Handler: runPythonHandler,
})
|
Each agent can have multiple tools, but in this architecture, we keep it focused:
- Python worker → 1 tool
- Time worker → 1 tool
- Quote worker → 1 tool
One agent, one purpose.
Running an agent
1
| func (a *Agent) Run(ctx context.Context, input string, messages []ChatCompletionMessage) ([]ChatCompletionMessage, string, error)
|
This is the core reasoning loop:
- Append user input to messages
- Call the model
- If tool calls → execute them → loop again
- If no tool calls → return final answer
This is the same loop from the first article.
But now it’s encapsulated.
Each agent runs this loop independently.
Here’s where it gets interesting.
1
| func (a *Agent) AsTool(name, description string) (Tool, error)
|
This method:
- Takes an agent
- Exports it as an OpenAI tool
- Returns a handler that delegates to the agent
Example:
1
2
3
4
| pythonTool, err := pythonWorker.AsTool(
"python_agent",
"Delegates to the Python specialist agent.",
)
|
Now the planner can call the Python worker like a function.
From the planner’s perspective:
1
2
3
4
5
6
7
8
9
| {
"type": "function",
"function": {
"name": "python_agent",
"parameters": {
"input": "calculate the square root of 144"
}
}
}
|
Behind the scenes:
- The planner’s tool handler is invoked
- It forwards the input to
pythonWorker.Run() - The Python worker executes its own loop
- It returns the result
- The planner receives it as a tool result
Agents calling agents.
That’s the architecture.
Building the system
Now let’s walk through the actual implementation.
We’ll break this into chunks just like the previous articles.
Imports and setup
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| package main
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"strings"
"time"
agpkg "agent/agent"
agentopenai "github.com/sashabaranov/go-openai"
)
|
Notice:
- We import our custom
agent package as agpkg - We alias
go-openai as agentopenai to avoid conflicts
This is a clean multi-package Go project now.
Creating the planner
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| func main() {
ctx := context.Background()
apiKey := os.Getenv("OPENAI_API_KEY")
baseURL := os.Getenv("OPENAI_BASE_URL")
model := os.Getenv("MODEL")
config := agentopenai.DefaultConfig(apiKey)
if baseURL != "" {
config.BaseURL = baseURL
}
client := agentopenai.NewClientWithConfig(config)
plannerName := "planner"
plannerDescription := "You are a planning and orchestration agent. You can delegate work to specialist agents via tools: python_agent (execute Python), time_agent (get current time), quote_agent (fetch a random quote). Decide which to call, call them, then compose the final answer for the user."
planner, err := agpkg.NewAgent(client, model, plannerName, plannerDescription)
if err != nil {
log.Fatal(err)
}
|
The planner:
- Has no direct tools
- Knows it can delegate
- Is responsible for composing the final answer
Creating worker agents
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| pythonWorker, err := agpkg.NewAgent(client, model, "python-worker", "You are a specialist agent for executing Python code. Use the run_python_code tool when you need to compute something. Return only the result.")
if err != nil {
log.Fatal(err)
}
timeWorker, err := agpkg.NewAgent(client, model, "time-worker", "You are a specialist agent for retrieving the current time. Use the get_time tool and return it.")
if err != nil {
log.Fatal(err)
}
quoteWorker, err := agpkg.NewAgent(client, model, "quote-worker", "You are a specialist agent for fetching a random inspirational quote. Use the get_random_quote tool and return it.")
if err != nil {
log.Fatal(err)
}
|
Each agent:
- Has a focused role
- Has a specialized system prompt
- Will receive focused tools next
Now we give each worker its tool:
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
| func registerWorkerTools(pythonWorker, timeWorker, quoteWorker *agpkg.Agent) error {
if err := timeWorker.AddTool(agpkg.Tool{
Definition: agentopenai.Tool{
Type: agentopenai.ToolTypeFunction,
Function: &agentopenai.FunctionDefinition{
Name: "get_time",
Description: "Returns the current server time",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{},
},
},
},
Handler: func(_ context.Context, _ string) (string, error) {
return fmt.Sprintf("Current server time: %s", time.Now().UTC().Format(time.RFC3339)), nil
},
}); err != nil {
return err
}
if err := pythonWorker.AddTool(agpkg.Tool{
Definition: agentopenai.Tool{
Type: agentopenai.ToolTypeFunction,
Function: &agentopenai.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"},
},
},
},
Handler: runPythonHandler,
}); err != nil {
return err
}
if err := quoteWorker.AddTool(agpkg.Tool{
Definition: agentopenai.Tool{
Type: agentopenai.ToolTypeFunction,
Function: &agentopenai.FunctionDefinition{
Name: "get_random_quote",
Description: "Fetches a random inspirational quote from the web.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{},
},
},
},
Handler: fetchRandomQuoteHandler,
}); err != nil {
return err
}
return nil
}
|
Each worker gets exactly one tool.
Focused competency.
Now the critical part:
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
| func registerPlannerTools(planner, pythonWorker, timeWorker, quoteWorker *agpkg.Agent) error {
pythonTool, err := pythonWorker.AsTool(
"python_agent",
"Delegates to the Python specialist agent.",
)
if err != nil {
return err
}
timeTool, err := timeWorker.AsTool(
"time_agent",
"Delegates to the time specialist agent.",
)
if err != nil {
return err
}
quoteTool, err := quoteWorker.AsTool(
"quote_agent",
"Delegates to the quote specialist agent.",
)
if err != nil {
return err
}
if err := planner.AddTool(pythonTool); err != nil {
return err
}
if err := planner.AddTool(timeTool); err != nil {
return err
}
if err := planner.AddTool(quoteTool); err != nil {
return err
}
return nil
}
|
Each worker is exported as a tool.
The planner’s tools are other agents.
This is the multi-agent pattern in action.
The main loop
The user-facing loop is now trivial:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| var messages []agentopenai.ChatCompletionMessage
reader := bufio.NewReader(os.Stdin)
fmt.Println("Multi-Agent Go CLI (type 'exit' to quit)")
for {
fmt.Print("\n> ")
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(input)
if input == "exit" {
return
}
var output string
messages, output, err = planner.Run(ctx, input, messages)
if err != nil {
log.Fatal(err)
}
fmt.Println("\nAssistant:", output)
}
|
Everything happens inside planner.Run().
We don’t manage tools.
We don’t manage routing.
We don’t manage sub-agent execution.
The agent system handles it all.
The Agent implementation
Now let’s look at the core agent logic in agent/agent.go.
The Run method
This is the main loop:
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
| func (a *Agent) Run(ctx context.Context, input string, messages []openai.ChatCompletionMessage) ([]openai.ChatCompletionMessage, string, error) {
// Ensure system prompt is set
if a.Description != "" {
if len(messages) == 0 {
messages = append(messages, openai.ChatCompletionMessage{
Role: openai.ChatMessageRoleSystem,
Content: a.Description,
})
} else if messages[0].Role == openai.ChatMessageRoleSystem {
messages[0].Content = a.Description
} else {
messages = append([]openai.ChatCompletionMessage{{
Role: openai.ChatMessageRoleSystem,
Content: a.Description,
}}, messages...)
}
}
messages = append(messages, openai.ChatCompletionMessage{
Role: openai.ChatMessageRoleUser,
Content: input,
})
for {
resp, err := a.Client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
Model: a.Model,
Messages: messages,
Tools: a.tools,
})
if err != nil {
return messages, "", err
}
msg := resp.Choices[0].Message
messages = append(messages, msg)
if len(msg.ToolCalls) == 0 {
return messages, msg.Content, nil
}
for _, toolCall := range msg.ToolCalls {
result := a.executeToolCall(ctx, toolCall)
messages = append(messages, openai.ChatCompletionMessage{
Role: openai.ChatMessageRoleTool,
Content: result,
ToolCallID: toolCall.ID,
})
}
}
}
|
This is the same loop we built in the first article.
But now:
- It’s reusable
- It’s encapsulated
- It’s composable
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| func (a *Agent) executeToolCall(ctx context.Context, tc openai.ToolCall) string {
name := tc.Function.Name
handler, ok := a.toolHandlers[name]
if !ok {
return "unknown tool: " + name
}
out, err := handler(ctx, tc.Function.Arguments)
if err != nil {
if out == "" {
return "tool error: " + err.Error()
}
return out + "\nerror: " + err.Error()
}
return out
}
|
Simple dispatch.
But remember:
When the planner calls python_agent, the handler is:
1
| pythonWorker.Run(ctx, args.Input, messages)
|
Tool execution = agent delegation.
Here’s how we export an agent as a tool:
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
| func (a *Agent) AsTool(name, description string) (Tool, error) {
if name == "" {
return Tool{}, errors.New("tool name is empty")
}
if description == "" {
description = "Delegates work to a sub-agent"
}
toolDef := openai.Tool{
Type: openai.ToolTypeFunction,
Function: &openai.FunctionDefinition{
Name: name,
Description: description,
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"input": map[string]any{
"type": "string",
"description": "Input message for the agent.",
},
},
"required": []string{"input"},
},
},
}
var messages []openai.ChatCompletionMessage
h := func(ctx context.Context, arguments string) (string, error) {
var args struct {
Input string `json:"input"`
}
if err := json.Unmarshal([]byte(arguments), &args); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
if args.Input == "" {
return "", errors.New("invalid arguments: missing 'input' string")
}
var out string
var err error
messages, out, err = a.Run(ctx, args.Input, messages)
return out, err
}
return Tool{Definition: toolDef, Handler: h}, nil
}
|
Critical detail:
1
| var messages []openai.ChatCompletionMessage
|
This is captured in the closure.
Each agent-as-tool maintains its own conversation history.
So if the planner calls python_agent multiple times:
- The Python worker remembers previous interactions
- It can refine its work
- It can reference past results
Stateful sub-agents.
This is powerful.
The actual tool implementations are unchanged from the previous article.
run_python_code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| func runPythonHandler(_ context.Context, arguments string) (string, error) {
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
}
|
get_random_quote
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| func fetchRandomQuoteHandler(_ context.Context, _ string) (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
}
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
}
|
Same implementation.
Different execution context.
Now they’re isolated inside specialist agents.
Full code
Here’s the complete main.go:
Click to expand main.go
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
| package main
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"strings"
"time"
agpkg "agent/agent"
agentopenai "github.com/sashabaranov/go-openai"
)
func main() {
ctx := context.Background()
apiKey := os.Getenv("OPENAI_API_KEY")
baseURL := os.Getenv("OPENAI_BASE_URL")
model := os.Getenv("MODEL")
config := agentopenai.DefaultConfig(apiKey)
if baseURL != "" {
config.BaseURL = baseURL
}
client := agentopenai.NewClientWithConfig(config)
plannerName := "planner"
plannerDescription := "You are a planning and orchestration agent. You can delegate work to specialist agents via tools: python_agent (execute Python), time_agent (get current time), quote_agent (fetch a random quote). Decide which to call, call them, then compose the final answer for the user."
planner, err := agpkg.NewAgent(client, model, plannerName, plannerDescription)
if err != nil {
log.Fatal(err)
}
pythonWorker, err := agpkg.NewAgent(client, model, "python-worker", "You are a specialist agent for executing Python code. Use the run_python_code tool when you need to compute something. Return only the result.")
if err != nil {
log.Fatal(err)
}
timeWorker, err := agpkg.NewAgent(client, model, "time-worker", "You are a specialist agent for retrieving the current time. Use the get_time tool and return it.")
if err != nil {
log.Fatal(err)
}
quoteWorker, err := agpkg.NewAgent(client, model, "quote-worker", "You are a specialist agent for fetching a random inspirational quote. Use the get_random_quote tool and return it.")
if err != nil {
log.Fatal(err)
}
if err := registerWorkerTools(pythonWorker, timeWorker, quoteWorker); err != nil {
log.Fatal(err)
}
if err := registerPlannerTools(planner, pythonWorker, timeWorker, quoteWorker); err != nil {
log.Fatal(err)
}
var messages []agentopenai.ChatCompletionMessage
reader := bufio.NewReader(os.Stdin)
fmt.Println("Multi-Agent Go CLI (type 'exit' to quit)")
for {
fmt.Print("\n> ")
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(input)
if input == "exit" {
return
}
var output string
messages, output, err = planner.Run(ctx, input, messages)
if err != nil {
log.Fatal(err)
}
fmt.Println("\nAssistant:", output)
}
}
func registerWorkerTools(pythonWorker, timeWorker, quoteWorker *agpkg.Agent) error {
if err := timeWorker.AddTool(agpkg.Tool{
Definition: agentopenai.Tool{
Type: agentopenai.ToolTypeFunction,
Function: &agentopenai.FunctionDefinition{
Name: "get_time",
Description: "Returns the current server time",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{},
},
},
},
Handler: func(_ context.Context, _ string) (string, error) {
return fmt.Sprintf("Current server time: %s", time.Now().UTC().Format(time.RFC3339)), nil
},
}); err != nil {
return err
}
if err := pythonWorker.AddTool(agpkg.Tool{
Definition: agentopenai.Tool{
Type: agentopenai.ToolTypeFunction,
Function: &agentopenai.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"},
},
},
},
Handler: runPythonHandler,
}); err != nil {
return err
}
if err := quoteWorker.AddTool(agpkg.Tool{
Definition: agentopenai.Tool{
Type: agentopenai.ToolTypeFunction,
Function: &agentopenai.FunctionDefinition{
Name: "get_random_quote",
Description: "Fetches a random inspirational quote from the web.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{},
},
},
},
Handler: fetchRandomQuoteHandler,
}); err != nil {
return err
}
return nil
}
func registerPlannerTools(planner, pythonWorker, timeWorker, quoteWorker *agpkg.Agent) error {
pythonTool, err := pythonWorker.AsTool(
"python_agent",
"Delegates to the Python specialist agent.",
)
if err != nil {
return err
}
timeTool, err := timeWorker.AsTool(
"time_agent",
"Delegates to the time specialist agent.",
)
if err != nil {
return err
}
quoteTool, err := quoteWorker.AsTool(
"quote_agent",
"Delegates to the quote specialist agent.",
)
if err != nil {
return err
}
if err := planner.AddTool(pythonTool); err != nil {
return err
}
if err := planner.AddTool(timeTool); err != nil {
return err
}
if err := planner.AddTool(quoteTool); err != nil {
return err
}
return nil
}
func runPythonHandler(_ context.Context, arguments string) (string, error) {
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
}
func fetchRandomQuoteHandler(_ context.Context, _ string) (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
}
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
}
|
And the complete agent/agent.go:
Click to expand agent/agent.go
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
| package agent
import (
"context"
"encoding/json"
"errors"
"fmt"
openai "github.com/sashabaranov/go-openai"
)
type ToolHandler func(ctx context.Context, arguments string) (string, error)
type Tool struct {
Definition openai.Tool
Handler ToolHandler
}
func (t Tool) Name() string {
if t.Definition.Function == nil {
return ""
}
return t.Definition.Function.Name
}
type Agent struct {
Client *openai.Client
Model string
Name string
Description string
tools []openai.Tool
toolHandlers map[string]ToolHandler
}
func NewAgent(client *openai.Client, model, name, description string) (*Agent, error) {
if client == nil {
return nil, errors.New("openai client is nil")
}
if model == "" {
model = "gpt-4o-mini"
}
if name == "" {
name = "agent"
}
if description == "" {
return nil, errors.New("agent description is required")
}
return &Agent{
Client: client,
Model: model,
Name: name,
Description: description,
tools: nil,
toolHandlers: map[string]ToolHandler{},
}, nil
}
func (a *Agent) Tools() []openai.Tool {
if len(a.tools) == 0 {
return nil
}
copyTools := make([]openai.Tool, len(a.tools))
copy(copyTools, a.tools)
return copyTools
}
func (a *Agent) AddTool(tool Tool) error {
if tool.Handler == nil {
return errors.New("tool handler is nil")
}
if tool.Definition.Type != openai.ToolTypeFunction {
return fmt.Errorf("unsupported tool type: %s", tool.Definition.Type)
}
if tool.Definition.Function == nil || tool.Definition.Function.Name == "" {
return errors.New("tool.Function.Name is required")
}
name := tool.Definition.Function.Name
a.toolHandlers[name] = tool.Handler
for i := range a.tools {
if a.tools[i].Function != nil && a.tools[i].Function.Name == name {
a.tools[i] = tool.Definition
return nil
}
}
a.tools = append(a.tools, tool.Definition)
return nil
}
func (a *Agent) Run(ctx context.Context, input string, messages []openai.ChatCompletionMessage) ([]openai.ChatCompletionMessage, string, error) {
if a.Description != "" {
if len(messages) == 0 {
messages = append(messages, openai.ChatCompletionMessage{
Role: openai.ChatMessageRoleSystem,
Content: a.Description,
})
} else if messages[0].Role == openai.ChatMessageRoleSystem {
if messages[0].Content != a.Description {
messages[0].Content = a.Description
}
} else {
messages = append([]openai.ChatCompletionMessage{{
Role: openai.ChatMessageRoleSystem,
Content: a.Description,
}}, messages...)
}
}
messages = append(messages, openai.ChatCompletionMessage{
Role: openai.ChatMessageRoleUser,
Content: input,
})
for {
resp, err := a.Client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
Model: a.Model,
Messages: messages,
Tools: a.tools,
})
if err != nil {
return messages, "", err
}
msg := resp.Choices[0].Message
messages = append(messages, msg)
if len(msg.ToolCalls) == 0 {
return messages, msg.Content, nil
}
for _, toolCall := range msg.ToolCalls {
result := a.executeToolCall(ctx, toolCall)
messages = append(messages, openai.ChatCompletionMessage{
Role: openai.ChatMessageRoleTool,
Content: result,
ToolCallID: toolCall.ID,
})
}
}
}
func (a *Agent) executeToolCall(ctx context.Context, tc openai.ToolCall) string {
name := tc.Function.Name
handler, ok := a.toolHandlers[name]
if !ok {
return "unknown tool: " + name
}
out, err := handler(ctx, tc.Function.Arguments)
if err != nil {
if out == "" {
return "tool error: " + err.Error()
}
return out + "\nerror: " + err.Error()
}
return out
}
func (a *Agent) AsTool(name, description string) (Tool, error) {
if name == "" {
return Tool{}, errors.New("tool name is empty")
}
if description == "" {
description = "Delegates work to a sub-agent"
}
toolDef := openai.Tool{
Type: openai.ToolTypeFunction,
Function: &openai.FunctionDefinition{
Name: name,
Description: description,
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"input": map[string]any{
"type": "string",
"description": "Input message for the agent.",
},
},
"required": []string{"input"},
},
},
}
var messages []openai.ChatCompletionMessage
h := func(ctx context.Context, arguments string) (string, error) {
var args struct {
Input string `json:"input"`
}
if err := json.Unmarshal([]byte(arguments), &args); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
if args.Input == "" {
return "", errors.New("invalid arguments: missing 'input' string")
}
var out string
var err error
messages, out, err = a.Run(ctx, args.Input, messages)
return out, err
}
return Tool{Definition: toolDef, Handler: h}, nil
}
|
Seeing the multi-agent system in action
Now let’s see how this actually works in practice.
When we run the program:
1
2
3
4
| $ OPENAI_BASE_URL=http://localhost:11434/v1 MODEL=ministral-3:3b go run main.go
Multi-Agent Go CLI (type 'exit' to quit)
>
|
Simple delegation
1
2
| > what time is it?
A: The current server time is 2026-03-01T17:30:45Z.
|
What happened behind the scenes:
- Planner received the question
- Planner identified it needs the time agent
- Planner called
time_agent tool - That delegated to
timeWorker.Run() - Time worker called
get_time tool - Result flowed back: worker → planner → user
Two agents.
One tool call.
Clean separation of concerns.
Multi-agent orchestration
1
2
3
4
5
| > calculate 144 * 89 and tell me the current time
A: The result of 144 × 89 is **12,816**.
The current server time is **2026-03-01T17:32:18Z**.
|
Behind the scenes:
- Planner decided it needs two agents
- Called
python_agent with “calculate 144 * 89”- Python worker generated:
print(144 * 89) - Executed and returned
12816
- Called
time_agent- Time worker returned current time
- Planner composed final answer
This is the power of the architecture:
- The planner doesn’t know how to calculate
- The planner doesn’t know how to get time
- It only knows who to ask
Delegation.
Complex multi-step reasoning
1
2
3
4
5
| > get a random quote, calculate its word count, then tell me if it's prime
A: The quote has **14 words**, and **14 is not a prime number** (it's divisible by 1, 2, 7, and 14).
The quote was: "The only way to do a great work is to love what you do." — Steve Jobs
|
Here the planner:
- Called
quote_agent → received quote - Called
python_agent with “count words in: [quote]”- Generated:
print(len("The only way...".split())) - Returned
14
- Called
python_agent again with “is 14 prime?”- Generated primality check code
- Returned
False
- Composed final narrative response
Three different agent calls.
Two calls to the same agent with different tasks.
Each worker maintained its own context.
The planner orchestrated everything.
Why this scales
In the monolithic version, this request would require:
- One agent with 3 tools
- One giant conversation history
- One context window juggling all tool definitions
In the multi-agent version:
- Planner sees 3 high-level operations
- Each worker handles focused execution
- Each worker has isolated context
- Tool complexity is hidden
As you add more capabilities:
- Add a new worker
- Give it specialized tools
- Export it to the planner
- Done
The planner doesn’t need to change.
The architecture scales horizontally.
What we’ve achieved
Let’s map out the progress across all three articles:
| Feature | Article 1 | Article 2 | Article 3 |
|---|
| Agent loop | ✅ | ✅ | ✅ |
| Tool calling | ✅ | ✅ | ✅ |
| Multiple tools | ❌ | ✅ | ✅ |
| Persistent memory | ❌ | ✅ | ❌ |
| Agent abstraction | ❌ | ❌ | ✅ |
| Multi-agent architecture | ❌ | ❌ | ✅ |
| Hierarchical delegation | ❌ | ❌ | ✅ |
| Isolated agent context | ❌ | ❌ | ✅ |
| Horizontal scalability | ❌ | ❌ | ✅ |
We removed persistent memory to focus on the architecture.
You can add it back by:
- Persisting each agent’s message history
- Loading it on startup
- Saving after each interaction
The pattern is orthogonal.
When to use multi-agent architecture
Not every system needs this.
Use multi-agent when:
- You have diverse capabilities (computation, web, database, etc.)
- Tool count is growing beyond 5-10
- Different tools require different context
- You want to isolate failure domains
- You need horizontal scaling
Stick with monolithic when:
- You have 1-3 simple tools
- All tools share the same context
- Speed is critical (each delegation = extra API call)
- You’re prototyping
Conclusion
We’ve come a long way across three articles.
Article 1: The foundation — understanding the agent loop.
Article 2: Practical capability — multiple tools, persistent memory.
Article 3: Scalable architecture — multi-agent composition.
You now have:
- A reusable agent abstraction
- A clean separation of concerns
- A scalable pattern for adding capabilities
- Full control over the execution flow
No magic.
No black boxes.
Just composition.
If you’re building AI-powered applications in Go, this pattern will serve you well.
And if you want higher-level abstractions, check out LinGoose — it provides production-ready implementations of these patterns and many more.
But now you understand what’s happening under the hood.
And that’s what matters.