Minimal Code: The Complete Single-Tool, Single-Round Loop (Part 2)
The through-line of this series is “explain one principle with the smallest possible example, then engineer it step by step”. This part is a deliberately minimal example: one tool, a single round, about 200 lines of Go (comments included), standard library only (net/http, encoding/json), running the complete “model names a tool → Go executes it → the result goes back → the model answers” loop.
- Who it’s for: you know some Go and are writing your first agent tool call (prerequisite: Ollama and
llama3.1:8binstalled as in Part 1) - Code:
demo/minimal-agent/main.go; rungo run ./demo/minimal-agentfrom the repository root - Requirements: Go 1.27+ (the repository’s go.mod already declares go 1.27.1)
This series assumes a Go developer. Readers working in Python or any other language can still read Part 1 and the mechanism and pitfall sections of every part — tool-calling mechanics, chat-template problems, API compatibility and operations concepts are all independent of the language.
Environment note: this article is based on measurements of Ollama 0.33.3 + llama3.1:8b (A770/Vulkan, 2026-09); Ollama iterates fast, so treat
ollama serve --helpas the source of truth for environment variables, and the official OpenAI compatibility docs as the source of truth for the/v1compatibility fields.
1. Interface baseline: /v1 is approximately compatible (know this before you write code)
In a hurry to get something running? Jump straight to Section 2, get one conversation working with curl / Go, and come back to this section’s compatibility details afterwards.
The code in this part calls POST http://localhost:11434/v1/chat/completions — it is merely the closest thing to OpenAI SDK syntax; officially it is positioned as an OpenAI compatibility layer, not a field-by-field equivalent. The differences in the table below were measured on Ollama 0.33.3 and checked against the official compatibility docs (the differences drift between versions — even the streaming message format was only aligned later); the code parts and Part 4 (turning this into a service, migrating to the cloud) both rely on it:
| Difference | Ollama (measured on 0.33.3) | Official OpenAI |
|---|---|---|
tool_choice parameter |
Partially supported and unstable (measured on 0.33.3: required does force a call, none has no effect; the official compatibility docs do not list it as a supported field and upstream is still working on it: #17921, #11171) |
Supports auto/required/a named function; commonly used to force a call |
Empty tools: [] |
Returns 200, same as sending no tools at all | Usually errors, or requires omitting the field (several SDKs write workarounds) |
message.content on tool calls |
The empty string "" |
null |
finish_reason |
tool_calls on tool calls (this version already matches OpenAI) |
tool_calls |
n / user / logit_bias |
Not supported | Supported |
| Streaming messages | Inconsistent with OpenAI at first (only aligned on choices[].delta after Ollama PR #17485); older versions still differ |
choices[].delta |
Three iron rules when writing code (the code in this part already follows them):
- Decide “is a tool call wanted?” from
tool_callsbeing non-empty /finish_reason == "tool_calls", never fromcontentalone; - When checking for emptiness, accept both
content == ""andcontent == null; - With no tools, omit the
toolsfield; never sendtools: []; - When you need to “force a particular tool”, don’t count on
tool_choice(measured on 0.33.3:requiredforces a call,nonehas no effect, field support is unstable) — constrain it through the prompt instead, or use the native/api/chat(Section 1 of Part 4 compares the native/api/chatmessage shapes, but the service sticks to/v1throughout so it can move to the cloud).
Reference: Ollama OpenAI compatibility official docs
2. Starting from plain text: the core logic in about 20 lines
First run one conversation by hand with curl — it is language-independent and the easiest way to confirm the service and the model are ready:
curl -s http://localhost:11434/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"llama3.1:8b","messages":[{"role":"user","content":"你好,用一句话介绍你自己。"}]}'
The JSON that comes back looks like this (formatted):
{
"id": "chatcmpl-9",
"model": "llama3.1:8b",
"choices": [
{
// ← Go: ChatResponse.Choices[]
"message": {
// ← Go: ChatResponse.Choices[].Message
"role": "assistant",
"content": "我是语言模型,能理解和生成汉语。" // ← the answer you asked for
},
"finish_reason": "stop" // ← the model is done (becomes "tool_calls" for tool calls, see §3)
}
],
"usage": {
// ← token counts, the cost behind "resend the full history" in Part 3
"prompt_tokens": 19,
"completion_tokens": 13
}
}
All you need is choices[0].message.content — that is the answer. The Go code later on defines its structs to match exactly this JSON shape.
Before “adding tools” to the code, think one question through: when do you not need an agent at all? For simple Q&A or chit-chat, /api/chat (without tools) is enough; for plain text completion, /api/generate is lighter (no chat template or tool-parsing overhead); deterministic tasks (table lookups, formatting) may not need a model at all. Only when a task needs real-world side effects or data (reading the clock, querying a database, running a command) and the execution path cannot be hard-coded in advance is the “model names a tool → code executes → result goes back” loop worth it — Part 3 explains that every round resends the full history, so the earlier you make this call, the more tokens you save.
And plain-text chat is the simplest form of that lightweight path: the same messages array posted to /v1/chat/completions, except that the request carries no tools field and the response carries only content, with no tool_calls.
Full code (demo/plain-chat/main.go, about 60 lines, standard library only):
Full source of demo/plain-chat/main.go (click to expand)
// Part 2 (the plain-text starting version) demo: the core logic runs one
// conversation in about 20 lines
//
// Relation to the tool version: plain-text chat is a "subset" of tool
// calling — the same /v1/chat/completions endpoint and the same messages
// array, except that:
// 1. the request carries no tools field (following the "omit tools when
// there are none" iron rule);
// 2. the response has only content, no tool_calls;
// 3. multi-turn chat = keep appending {role,content} to messages and
// resending the whole array.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
type ChatRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Temperature float64 `json:"temperature"`
}
type Choice struct {
Message Message `json:"message"`
FinishReason string `json:"finish_reason"`
}
type ChatResponse struct {
Choices []Choice `json:"choices"`
}
func main() {
messages := []Message{
{Role: "user", Content: "你好,请用一句话介绍你自己。"},
}
reqBody := ChatRequest{
Model: "llama3.1:8b",
Messages: messages,
Temperature: 0, // greedy decoding for the demo: stable, reproducible output
}
jsonData, _ := json.Marshal(reqBody)
resp, err := http.Post("http://localhost:11434/v1/chat/completions",
"application/json", bytes.NewBuffer(jsonData))
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result ChatResponse
if err := json.Unmarshal(body, &result); err != nil {
fmt.Printf("解析失败,原始响应: %s\n", string(body))
panic(err)
}
if len(result.Choices) == 0 {
fmt.Printf("无响应,原始响应: %s\n", string(body))
return
}
c := result.Choices[0]
fmt.Println("🧑 用户:", messages[0].Content)
fmt.Println("🤖", c.Message.Content)
fmt.Println(" finish_reason:", c.FinishReason)
}
Three points:
- The request body holds only
model+messages, notools— this is the iron rule from Section 1, “omit thetoolsfield when there are no tools”, put into practice; - The response is read from
choices[0].message.contentandfinish_reason(stophere); - Multi-turn chat = append to
messagesand resend the whole array, which is the shared foundation of every later demo.
Running it:
go run ./demo/plain-chat
Real output (Ollama 0.33.3 + llama3.1:8b, measured 2026-09):
🧑 用户: 你好,请用一句话介绍你自己。
🤖 你好!我是 LLaMA,一个由 Meta 开发的基于人工智能的语言模型,能够理解和生成人类语言。
finish_reason: stop
From this section to the next, only “three things” differ: add the
toolsfield to the request, parsetool_callsout of the response, and send the execution result back withrole="tool". The tool version’s structs andsendRequestare nearly identical to this section — so a tutorial could equally well start with tools, but seeing plain text first makes it easier to build intuition.
3. The Go implementation (adding tools to plain text)
This is the deliberately minimal example: one tool, a single round, about 200 lines (comments included), Go standard library only (net/http, encoding/json).
The design trade-offs are below; understand the principle first, then engineer it:
| Deliberately simplified | What production does |
|---|---|
| Only one tool, only a single round | Multi-tool registration + a while loop until the model stops requesting tools |
HTTP errors go straight to panic |
Return an error and degrade or retry gracefully |
json.Marshal and io.ReadAll errors are ignored |
Handle each one and attach context |
The model name llama3.1:8b is hard-coded |
Move it into configuration (flag / environment variable) |
| A fixed 30s timeout, no concurrency control | http.Client timeouts + a connection pool |
Reads only message.content/tool_calls, ignores finish_reason |
Follows the compatibility list in Section 1 (tool_choice, empty tools, …) |
Full code (main.go)
Look at the raw request and response JSON first — the structs in the code are defined straight from these two JSON documents:
Request (with the tools array):
{
"model": "llama3.1:8b",
"messages": [{ "role": "user", "content": "现在几点了?" }],
"tools": [
{
// ← Go: ChatRequest.Tools []Tool
"type": "function",
"function": {
"name": "get_current_time",
"description": "获取当前时间",
"parameters": { "type": "object", "properties": {} }
}
}
]
}
Response (measured on Ollama 0.33.3):
{
"choices": [
{
"message": {
// ← Go: ChatResponse.Choices[].Message
"role": "assistant",
"content": "", // ⚠️ the empty string, not null
"tool_calls": [
{
// ← Go: Message.ToolCalls []ToolCall
"id": "call_dlj5358x", // ⚠️ correlation ID: you must send this back with the result
"function": {
"name": "get_current_time",
"arguments": "{}" // ⚠️ this is a string, not an object! parse it again with json.RawMessage
}
}
]
},
"finish_reason": "tool_calls" // ⚠️ not "stop" (the loop criterion in Part 3)
}
],
"usage": { "prompt_tokens": 146, "completion_tokens": 14 }
}
With those two JSON documents in view, the Go code that follows explains itself: why ToolCall.Function.Arguments needs a second parse through json.RawMessage, and what ToolCallID is for.
Full source of demo/minimal-agent/main.go (click to expand)
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// ===========================================
// Data structures (matching the Ollama API's JSON format)
// ===========================================
// Message is one message in the conversation
type Message struct {
Role string `json:"role"`
Content string `json:"content,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
// ToolCall is a tool the AI asked to call
type ToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}
// Tool is one of the tools we offer to the AI
type Tool struct {
Type string `json:"type"`
Function Function `json:"function"`
}
// Function is the definition of a tool function
type Function struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters map[string]interface{} `json:"parameters"`
}
// ChatRequest is the request sent to Ollama
type ChatRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Tools []Tool `json:"tools"`
Stream bool `json:"stream"`
}
// Choice is one generation choice (including its finish reason)
type Choice struct {
Message Message `json:"message"`
FinishReason string `json:"finish_reason"`
}
// ChatResponse is the response Ollama returns
type ChatResponse struct {
Choices []Choice `json:"choices"`
}
// ===========================================
// Tool functions (the logic that actually runs)
// ===========================================
// getCurrentTime is the tool function we implement
// It runs when the AI decides to call "get_current_time"
// args are the arguments the AI passes in (there are none in this example, so they are ignored)
func getCurrentTime(args json.RawMessage) string {
return time.Now().Format("2006-01-02 15:04:05")
}
// toolMap is the tool registry: it finds the function by name
// When the AI returns tool_calls, this table resolves the function to run
var toolMap = map[string]func(json.RawMessage) string{
"get_current_time": getCurrentTime,
}
// ===========================================
// Main program (demonstrates the complete tool-calling flow)
// ===========================================
func main() {
// Step 1: prepare the user message
// This is the question the user asks the AI
messages := []Message{
{Role: "user", Content: "现在几点了?告诉我当前的具体时间。"},
}
// Step 2: define the available tools
// Tell the AI which tools exist, and what each one does and takes
tools := []Tool{
{
Type: "function",
Function: Function{
Name: "get_current_time",
Description: "获取当前的日期和时间",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
},
},
}
// Step 3: send the request to Ollama
// Send the user message and the tool list to the model together
resp := sendRequest(messages, tools)
if len(resp.Choices) == 0 {
fmt.Println("无响应")
return
}
// Take the AI's reply
assistantMsg := resp.Choices[0].Message
messages = append(messages, assistantMsg)
// Step 4: check whether the AI wants to call a tool
if len(assistantMsg.ToolCalls) > 0 {
// The AI decided to call a tool
toolCall := assistantMsg.ToolCalls[0]
fmt.Println("🔧 模型决定调用工具:", toolCall.Function.Name)
// Step 5: run the tool
// Resolve the function through the tool registry and run it (a miss is
// usually the model hallucinating a tool name — don't panic)
fn, ok := toolMap[toolCall.Function.Name]
if !ok {
fmt.Println("⚠️ 未知工具,跳过:", toolCall.Function.Name)
return
}
result := fn(json.RawMessage(toolCall.Function.Arguments))
// Step 6: add the tool result to the conversation as a new message
// Note: Role must be "tool", and ToolCallID must match the ID the AI asked with
messages = append(messages, Message{
Role: "tool",
ToolCallID: toolCall.ID,
Content: result,
})
fmt.Println("✅ 工具结果:", result)
// Step 7: send the conversation, now containing the tool result, to the model again
// The model produces its final answer from the tool result
finalResp := sendRequest(messages, tools)
if len(finalResp.Choices) > 0 {
fmt.Println("💬 最终回答:", finalResp.Choices[0].Message.Content)
}
} else {
// The AI did not call a tool, so print its answer directly
fmt.Println("💬 回答:", assistantMsg.Content)
}
}
// ===========================================
// HTTP request function (talking to Ollama)
// ===========================================
// sendRequest posts a request to the Ollama API
// Using the OpenAI-compatible format: /v1/chat/completions
func sendRequest(messages []Message, tools []Tool) ChatResponse {
// Build the request body
reqBody := ChatRequest{
Model: "llama3.1:8b", // the model to use
Messages: messages, // conversation history
Tools: tools, // available tools
Stream: false, // no streaming output
}
// Serialize to JSON
jsonData, _ := json.Marshal(reqBody)
// POST to Ollama (a Client with a timeout instead of bare http.Post, so that
// a stuck upstream cannot hang us forever)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Post(
"http://localhost:11434/v1/chat/completions",
"application/json",
bytes.NewBuffer(jsonData),
)
if err != nil {
panic(err)
}
defer resp.Body.Close()
// Read the response
body, _ := io.ReadAll(resp.Body)
// Parse the JSON response
// Note: when Ollama reports an error the body is not the standard shape, so
// parsing it directly yields an empty response; silently printing "无响应"
// would make newcomers think the model is not installed, so print the raw
// body on failure
var result ChatResponse
if err := json.Unmarshal(body, &result); err != nil {
fmt.Printf("解析失败,原始响应: %s\n", string(body))
panic(err)
}
return result
}
Code walkthrough
The code has three parts, and understanding their responsibilities is enough:
1. Data structures (the first half of the file)
Message, ToolCall, Tool and friends map one-to-one onto the Ollama API’s JSON fields (through the json:"..." tags). The most confusing one is Message:
- a
role="assistant"message carriestool_calls(which tool the AI wants to call) - a
role="tool"message carriestool_call_id(the tool’s result, sent back to the AI)
2. Tool definition and registry
// Tool implementation: this runs when the model calls "get_current_time"
func getCurrentTime(args json.RawMessage) string {
return time.Now().Format("2006-01-02 15:04:05")
}
// Registry: the model only sends a tool "name"; the program uses this table
// to find the matching Go function
var toolMap = map[string]func(json.RawMessage) string{
"get_current_time": getCurrentTime,
}
3. The main flow main() (matching the sequence diagram below)
The comments inside main() walk through “step one” to “step seven”, matching the sequence diagram below one-to-one:
sequenceDiagram
participant U as User
participant G as Go program (main)
participant O as Ollama (llama3.1:8b)
U->>G: Ask a question
G->>O: POST /v1/chat/completions (messages + tools)
O-->>G: assistant message + tool_calls
G->>G: look up toolMap and run the Go function locally
G->>O: send the result back as role="tool" and request again
O-->>G: final answer in content
G-->>U: print the result
If your blog has Mermaid disabled, the flow reads: user → request with tools → receive tool_calls → run the tool locally → send the result back as role=“tool” → get the final answer (matching the step table below).
| Step | What it does |
|---|---|
| Step 1 | Prepare the user message (role="user") |
| Step 2 | Define the list of available tools (tell the model “this tool exists”) |
| Step 3 | Call sendRequest to send it to the model |
| Step 4 | Check whether the returned assistant message carries tool_calls |
| Step 5 | Look the tool name up in toolMap and run the matching Go function |
| Step 6 | Wrap the result in a role="tool" message, with ToolCallID matching the model’s call ID |
| Step 7 | Call sendRequest again so the model produces its final answer from the real result |
Key point: the
ToolCallIDin step six must match theIDin the model’s request, so that the model can tie the result to that specific call. The heart of the whole mechanism is this — the model does not execute tools, it only names them; execution always happens in your local code.
The HTTP request function sendRequest
The only place in the program that talks to Ollama:
- In the request body,
Modelsets the model name,Messagescarries the full conversation history, andToolscarries the tool list - Requests go to the OpenAI-compatible endpoint
POST http://localhost:11434/v1/chat/completions - The response is parsed and returned as a
ChatResponse, andmain()reads the AI’s reply fromChoices[0].Message
First steps toward production: four small changes (the transition from 200 lines to engineering)
The table above only gives direction; here are the smallest versions of each change. The fuller evolution unfolds part by part in Parts 3 and 4 inside the demo directory.
① A fixed 30s timeout → a configurable timeout budget
The minimal version already sends requests with a 30s-timeout http.Client (see sendRequest in the full code above); this step turns the timeout into a budget you can tune per scenario:
// Before (the minimal version: a fixed 30 seconds)
client := &http.Client{Timeout: 30 * time.Second}
// After (long contexts / slow cloud inference need headroom)
client := &http.Client{Timeout: 5 * time.Minute}
What matters is not the number but giving the upstream request an explicit timeout budget: without one, a stuck Ollama hangs the request forever.
② panic → returning an error
// Before
func sendRequest(...) ChatResponse { ...; panic(err) }
// After
func sendRequest(...) (ChatResponse, error) { ...; return ChatResponse{}, fmt.Errorf("请求失败: %w", err) }
The main flow then goes from “crashing” to “logging and degrading gracefully”.
③ A hard-coded model name → an environment variable
model := os.Getenv("OLLAMA_MODEL")
if model == "" {
model = "llama3.1:8b"
}
④ One round → a loop (the subject of Part 3)
Wrap “receive tool_calls → execute → send back” in a for until the response no longer contains tool_calls — that is the subject of Part 3, which also fills in multi-tool registration, argument parsing and error feedback.
Summary: those four points, plus the loop, multiple tools and error handling of Part 3, and the service-ization, timeouts and concurrency of Part 4, are the concrete path to the right-hand column of “deliberately simplified vs. production practice” — each part moves one small step forward, and there is no need to write “production-grade” code in one go.
4. Run results
go run ./demo/minimal-agent
Successful output:
🔧 模型决定调用工具: get_current_time
✅ 工具结果: 2026-09-07 19:58:44
💬 最终回答: 当前时间是 2026年09月07日 19:58:44
The agent really did execute my Go function and take the real time, instead of making an answer up.
Comparison: the same program with qwen2.5-coder
The same program with qwen2.5-coder:7b (the official template already carries the tool format, and the model still does not comply) writes the tool call as ordinary text:
💬 回答: {"name": "get_current_time", "arguments": {}}
It “knows” it should call a tool but never writes it into the tool_calls field — a stable reproduction with qwen2.5-coder + Ollama 0.33.3 (for the cause and the self-check, see Appendix A of Part 1). When you see output like this, check the model and the template first, rather than doubting your own code.
FAQ: quick answers (code)
| Problem | Cause | Fix |
|---|---|---|
| The code returns 404 / 400 | The Ollama version is too old and /v1/chat/completions is not enabled |
Upgrade to >= 0.3.0 (see the install in Part 1) |
The model’s answer contains {"name": ...} |
The template lacks the tool format, or model compliance is insufficient (qwen2.5-coder reproduces this on 0.33.3 even with a correct official template) | Switch to llama3.1:8b; for the Qwen transcript and self-check see Appendix A of Part 1 |
| Behavior differs after switching to OpenAI / a cloud service | /v1 is only approximately compatible, so field details differ |
Self-test against the list in Section 1 (empty tools, empty content, tool_choice, …) |
Conclusion
- Agents in Go are viable: without touching Python, about 200 lines of standard-library code complete the full tool-calling loop;
- This is the minimal example: it covers only the core principle (one tool, one round); multiple tools and multi-round loops are the subject of Part 3;
- /v1 is approximately compatible: this part’s decision rules (
tool_callsnon-empty + omitting emptytools) keep behavior identical on Ollama and OpenAI; - Qwen’s pitfalls depend on the model line: qwen2.5-coder is a model-compliance problem (it still fails with a correct official template), not “Qwen is hopeless” — for the transcript see Appendix A of Part 1.
Next up: “The Loop: Letting the Agent Decide How Many Tools to Call” — multi-tool registration, argument parsing, feeding tool errors back, and history trimming.

