Building a simple AI Agent

Google Cloud describes AI agents as “software systems that use AI to pursue goals and complete tasks on behalf of users.” They are becoming increasingly popular in various domains, including software development, project management, office suite and more. In this article, we will build a simple AI Agent that can perform basic tasks backed by a large language model (LLM).

How does an AI Agent work?

In simple terms, an AI Agent takes input from the user, passes it to an LLM for processing, interprets the result from the LLM and returns the final output to the user. It can happen that LLMs ask for additional information; in that case the Agent is responsible for providing it. This means that for a single input from the user, the Agent can talk with LLM multiple times.

Let’s take a look at the interaction between User, Agent and LLM for the task Show me all third party libraries used in the project in the current directory.

Note: The user only interacts with the Agent. It is the Agent who talks with LLM. I have omitted it in the below dialogue when Agent is simply relaying input from user to LLM for brevity.

  • User: Show me all third party libraries used in the project in the current directory.
  • LLM: Run the list_files path:. tool
  • Agent: main.go, go.mod
  • LLM: I’ll help you identify the third-party libraries used in the project. Let me check the go.mod file, which contains the dependency information for Go projects.
  • LLM: Run the read_file with path:go.mod tool
  • Agent: <contents of go.mod>
  • LLM: Based on the go.mod file, the project uses one third-party library:
    • golang.org/x/net version v0.44.0 This is the only external dependency listed in the project’s Go module file. The library is part of the Go standard library extensions and provides additional network-related functionality.

The control flow can be represented as a flowchart as shown below.

Agent

Now that we have a basic understanding of how an AI Agent works, let’s start with our implementation.

A barebones implementation

We will use Ollama as the LLM provider as it can run locally and is available across different platforms. Ollama provides a server REST API, which we can consume using Golang.

Let’s use the api/chat endpoint from Ollama to interact with the LLM.

The structure of the messages for the Ollama endpoints for chat is as follows.

# Request
curl http://localhost:11434/api/chat -d '{
  "model": "qwen3-16k",
  "messages": [
    {
      "role": "user",
      "content": "how are you?"
    },
    {
      "role": "assistant",
      "content": "I am good and you?"
    },
    {
      "role": "user",
      "content": "i am fine too"
    }
  ],
  "stream": false
}'
shell
# Response
{
  "model": "qwen3-16k",
  "created_at": "2025-1-1T16:10:00.000000Z",
  "message": {
    "role": "assistant",
    "content": "Good to hear it."
  },
  "done": true,
  "total_duration": ...,
  "load_duration": ...,
  "prompt_eval_count": ..,
  "prompt_eval_duration": ...,
  "eval_count": ...,
  "eval_duration": ... 
}
shell

We can send a similar request with Go, using the following code.

// This is the JSON object we need to send to api/chat endpoint.
type chatRequest struct {
    Model    string        `json:"model"`
    Messages []chatMessage `json:"messages"`
    // We will ignore the Tool calling for now
    // Tools    []toolDef     `json:"tools,omitempty"`
    Stream   bool          `json:"stream"`
    Options  any           `json:"options,omitempty"`
}

// Structure of the User message inside chatRequest to Ollama. The ToolCallID is required when we are supporting tool calling functionality.
// It is used to identify the tool which was called.
type chatMessage struct {
    Role       string `json:"role"`
    Content    string `json:"content,omitempty"`
    ToolCallID string `json:"tool_call_id,omitempty"`
    Name       string `json:"name,omitempty"`
}

// Structure of the JSON response from Ollama
type chatResponse struct {
    Model      string           `json:"model"`
    CreatedAt  string           `json:"created_at"`
    Message    assistantMessage `json:"message"`
    Done       bool             `json:"done"`
    DoneReason string           `json:"done_reason"`
}

// Structure of LLM model response inside chatResponse from Ollama
type assistantMessage struct {
    Role      string     `json:"role"`
    Content   string     `json:"content,omitempty"`
    // We will ignore the Tool calling for now
    // ToolCalls []toolCall `json:"tool_calls,omitempty"`
}

// sendToOllama calls Ollama /api/chat with messages
func sendToOllama(ctx context.Context, chats []chatMessage) (chatMessage, error) {
	if len(chats) == 0 {
		return chatMessage{}, errors.New("messages must not be empty")
	}

	// Hardcoded for now
	model = "qwen3-16k"
	endpoint = "http://localhost:11434/api/chat"

	// Create context with timeout to avoid hanging requests
	if _, ok := ctx.Deadline(); !ok {
		var cancel context.CancelFunc
		ctx, cancel = context.WithTimeout(ctx, 60*time.Second)
		defer cancel()
	}

	messages := chats
	reqBody := chatRequest{
		Model:    model,
		Stream:   false,
		Messages: messages,
	}
	payload, err := json.Marshal(reqBody)
	if err != nil {
		return chatMessage{}, fmt.Errorf("marshal request: %w", err)
	}

	// Boilerplate code to send a request and get response
	httpClient := &http.Client{Timeout: 0} // rely on context timeout
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
	if err != nil {
		return chatMessage{}, fmt.Errorf("create request: %w", err)
	}
	req.Header.Set("Content-Type", "application/json")

	resp, err := httpClient.Do(req)
	if err != nil {
		return chatMessage{}, fmt.Errorf("request ollama: %w", err)
	}
	body, err := io.ReadAll(resp.Body)
	resp.Body.Close()
	if err != nil {
		return chatMessage{}, fmt.Errorf("read response: %w", err)
	}
	if resp.StatusCode != http.StatusOK {
		return chatMessage{}, fmt.Errorf("ollama error: status %d, body: %s", resp.StatusCode, string(body))
	}

	var chatResp chatResponse
	if err := json.Unmarshal(body, &chatResp); err != nil {
		return chatMessage{}, fmt.Errorf("decode response: %w; body: %s", err, string(body))
	}

	if chatResp.Message.Content != "" {
		return chatMessage{Role: chatResp.Message.Role, Content: chatResp.Message.Content}, nil
	}

	if chatResp.Done {
		return chatMessage{Role: "assistant", Content: ""}, nil
	}

	return chatMessage{}, fmt.Errorf("something went wrong")
}
go

With the above code, we can talk with the Ollama API. Now the only thing missing is our Agent getting input from the user so that we can send it to the API.

// Agent only needs user input for now
type Agent struct {
	getUserMessage func() (string, bool)
}

// The run methods simply reads from user and sends it to LLM in a loop
func (agent *Agent) Run(ctx context.Context) error {
	conversations := []chatMessage{}
	fmt.Println("Chat with Ollama")
	for {
		fmt.Print("\u001b[94mYou\u001b[0m: ")
		message, ok := agent.getUserMessage()
		if !ok {
			break
		}
		conversations = append(conversations, chatMessage{Role: "user", Content: message})

		if len(conversations) == 0 {
			return errors.New("chat is empty")
		}

		reply, err := sendToOllama(ctx, conversations)
		if err != nil {
			return err
		}

		// We need to keep track of the whole conversation and always send it as input to LLM
		// An LLM is like a pure function, it has no state. So we always need to pass the whole context.
		conversations = append(conversations, reply)

		fmt.Printf("\u001b[93mOllama\u001b[0m: %s\n", reply.Content)
	}
	
	return nil
}

// Main function which ties everything together
func main() {
	scanner := bufio.NewScanner(os.Stdin)
	getUserMessage := func() (string, bool) {
		if !scanner.Scan() {
			return "", false
		}
		return scanner.Text(), true
	}
	agent := &Agent{
		getUserMessage: getUserMessage,
	}
	err := agent.Run(context.TODO())
	if err != nil {
		fmt.Println(err)
	}
}
go

That’s it; we have a basic chat functionality, which keeps track of the context. Let’s see if it works.

go run main.go
shell
  • Chat with Ollama
  • You: how are you?
  • Ollama: I’m doing great, thank you for asking! 😊 How can I assist you today?
  • You: what was my previous question?
  • Ollama: Your previous question was: “how are you?” 😊

It works!. But as of now it simply relays the user input to LLM. Let’s make it a real Agent AI, with tool calling capabilities, so that it can do tasks which require multiple steps.

To enable tool calling in Ollama, we need to add a few more structs to match the API with supports tool calling.

# Request
curl http://localhost:11434/api/chat -d '{
  "model": "qwen3-16k",
  "messages": [
    {
      "role": "user",
      "content": "Hello World"
    }
  ],
  "stream": false,
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "echo",
        "description": "Echo back the provided text",
        "parameters": {
          "type": "object",
          "properties": {
            "content": {
              "type": "string",
              "description": "Some text content"
            },
          },
          "required": ["content""]
        }
      }
    }
  ]
}'
shell
# Response
{
  "model": "qwen3-16k",
  "created_at": "2025-1-1T16:10:00.000000Z",
  "message": {
    "role": "assistant",
    "content": "",
    "tool_calls": [
      {
        "function": {
          "name": "echo",
          "arguments": {
            "content": "Hello World"
          }
        }
      }
    ]
  },
  "done_reason": "stop",
  "done": true,
  "total_duration": ...,
  "load_duration": ...,
  "prompt_eval_count": ...,
  "prompt_eval_duration": ...,
  "eval_count": ...,
  "eval_duration": ..., 
}
shell

We can model the same in Golang as shown below. We will add two tools a time_now and echo.

// We define our tools
tools := []toolDef{
	{
		Type: "function",
		Function: functionDef{
			Name:        "time_now",
			Description: "Return the current local time in RFC3339 format",
			Parameters: map[string]any{
				"type":                 "object",
				"properties":           map[string]any{},
				"additionalProperties": false,
			},
		},
	},
	{
		Type: "function",
		Function: functionDef{
			Name:        "echo",
			Description: "Echo back the provided text",
			Parameters: map[string]any{
				"type": "object",
				"properties": map[string]any{
					"text": map[string]any{"type": "string"},
				},
				"required":             []string{"text"},
				"additionalProperties": false,
			},
		},
	},
}

// We provide the functionality for tools here.
execTool := func(name string, args map[string]any) (string, error) {
	switch name {
	case "time_now":
		return time.Now().Format(time.RFC3339), nil
	case "echo":
		v, _ := args["text"].(string)
		return v, nil
	default:
		return "", fmt.Errorf("unknown tool: %s", name)
	}
}
go

Now we just need to change our sendToOllamaWithModel() function with these tools.

So the code will look like as follows.

func sendToOllamaWithModel(ctx context.Context, text []chatMessage) (chatMessage, error) {
	....
    existing code
    ....
    // We will run the loop up to maxSteps to make sure we handle all tool calls that LLM asks us to run inside the response from Ollama.
    // Below lines is what makes our Agent, an Agent :D 
	for step := 0; step < maxSteps; step++ {
		// If assistant returned tool calls, execute them and continue the loop
		if len(chatResp.Message.ToolCalls) > 0 {
			// Append assistant tool-calling message to history
			messages = append(messages, chatMessage{Role: chatResp.Message.Role, Content: chatResp.Message.Content})
			for _, tc := range chatResp.Message.ToolCalls {
				args := map[string]any{}
				result, err := execTool(tc.Function.Name, args)
				if err != nil {
					result = fmt.Sprintf("tool error: %v", err)
				}
				messages = append(messages, chatMessage{
					Role:       "tool",
					Content:    result,
					ToolCallID: tc.ID,
					Name:       tc.Function.Name,
				})
			}
			continue
		}
	}
go

Now we have everything, let’s give it a try.

go run main.go
shell
  • Chat with Ollama
  • You: Tell me back, what I tell you. Good morning
  • LLM: echo map[]
  • Ollama: Good morning!
  • You: What is the time now?
  • LLM: time_now map[]
  • Ollama: The current time is 22:47:25 on December 20, 2025.

As you can see the LLM used echo and time_now tool we added, even though echo was not properly used.

Let’s try one more example to check if our Agent can do multistep tasks.

go run main.go
shell
  • Chat with Ollama
  • You: Tell me the current time in a loop 3 times. Make sure you wait 2 seconds before checking the time again.
  • LLM: time_now map[]
  • LLM: time_now map[]
  • LLM: time_now map[]
  • LLM: time_now map[]
  • LLM: time_now map[]
  • LLM: time_now map[]
  • LLM: time_now map[]
  • LLM: time_now map[]
  • LLM: time_now map[]
  • Ollama: The current time has been checked three times, with a 2-second delay between each check. Here are the results:
  1. 2025-12-20T22:53:49+01:00
  2. 2025-12-20T22:53:50+01:00
  3. 2025-12-20T22:53:51+01:00

As you can see, even though LLM is not accurate here, our Agent did run it in a loop and proved it can do multistep tasks :D

So that is our simple AI Agent is less than 300 LOC. You can see the full commit here.

Conclusion

As you have already seen, extending the Agent capabilities by adding new tools or functionality should be straightforward. For example, this commit shows how to add execute shell commands tool to our Agent.

The repo (along with a few minor improvements) can be found at this link.

Happy hacking and see you next time :)