In the first post of this series we shortlisted ten open-source coding agents that we want to understand in depth. Before we get there, we have to explain the general architecture of a coding agent. While they're all different in the details, they share the anatomy.

We will build a tiny coding agent, purposely simplified for educational purposes. We call it Pinocchio: a little puppet that wants to be a real agent. A puppet is a good metaphor of how we think about coding agents in our own workflow: an extremely fast, extremely tireless apprentice that only moves when we pull the strings. And like any good puppet story, this one comes with a lesson about what's real and what's wood.

We're building it in Go because it's the simplest language we know and we pretty much default to it every time we're building a CLI. Also for simplicity's sake, Pinocchio integrates only with DeepSeek, our daily driver.

Why 5 versions?

A coding agent is, at its very core, a program you use to ask LLMs to execute actions on your behalf.

While you don't need that much code to build a (radically) simple version there's a lot going on conceptually so we'll break how we build this in 5 steps:

  1. The loop: A puppet that can't move yet: you can talk to Pinocchio, it'll talk back but it won't execute any action for you.
  2. Tool calls: With tools, Pinocchio can do things on your behalf.
  3. Permissions: Unless you constrain it, Pinocchio will happily set your computer on fire.
  4. Context management: The longer you talk, the more Pinocchio has to remember so context management gives it a better memory.
  5. Sessions: Pinocchio is never tired but now if you want you can take a break and then resume the conversation.

Version 1: the loop

The most basic version of an agent loop is... a loop. The user (you) asks one question and the agent (Pinocchio) gives you one answer. We call this exchange a turn.

Here's the anatomy that matters: the shape of a message, the question we send, the answer we get back, and the loop itself. The HTTP call lives in a small chat helper we leave out of the way — you can read the full source.

type Message struct {	Role    string `json:"role"`	Content string `json:"content"`}type chatRequest struct {	Model    string    `json:"model"`	Messages []Message `json:"messages"`}func main() {	var messages []Message	in := bufio.NewScanner(os.Stdin)	for {		fmt.Print("> ")		if !in.Scan() {			return		}		text := strings.TrimSpace(in.Text())		if text == "" {			continue		}		messages = append(messages, Message{Role: "user", Content: text})		response, err := chat(messages)		if err != nil {			panic(err)		}		messages = append(messages, response)		fmt.Println(response.Content)	}}
writing/pinocchio/v1/main.go

You may be tempted to think that this code is overly simplified for editorial reasons but the truth is the agent loop is genuinely tiny. In a production grade coding agent the part that take thousands of engineering hours lives around the loop.

Also notice that the conversation has strictly defined roles for each message so it can tell who's who (human, system, assistant, tools). More on this later.

Now let's try it:

go run v1/main.go
> review v1/main.go in this directory
I don’t have access to your local filesystem or
directory contents, so I can’t open or
review `v1/main.go` directly.

Please paste the code from `v1/main.go` here,
and let me know what kind of review you’re looking for,
for example:

- Bug fixes
- Code style / Go best practices
- Performance improvements
- Concurrency safety
- API design
- Testing suggestions

Once I can see the code, I’ll give you a detailed review.

It works 🎉 but... we hit the most obvious limitation. Our v1 program is nothing more than a repackaged chat. You can ask questions, you get answers but it's all talk and no action. This is where tools come in.

Version 2: tools

Tools draw the line that separates an AI chat from an agent. Tools is how LLMs gain the ability to execute actions for you.

It works this way: you pass tool definitions to the LLM along with your messages so it knows what kind of actions are available.

If needed, the LLM adds tool calls to its answers. Then the agent is responsible for executing the calls and append call results to the message chain.

This has a deep implication on the structure of the agent loop code because, in order to achieve "autonomous actions", the LLM and the agent may have to exchange messages within the same turn. In practice, this means we need to add an inner loop inside the main loop.

This may be harder to explain in words than in code so let us show you how we added three tools (read/write/list files) to Pinocchio.

First of all, we need to hand to the model a JSON schema definition of the tools Pinocchio supports and update our message definitions for the HTTP calls to the LLM:

@@ -1,9 +1,58 @@1+var tools = []map[string]any{2+	{3+		"type": "function",4+		"function": map[string]any{5+			"name":        "read_file",6+			"description": "Read a file from the current directory and return its full contents",7+			"parameters": map[string]any{8+				"type": "object",9+				"properties": map[string]any{10+					"path": map[string]any{"type": "string"},11+				},12+				"required": []string{"path"},13+			},14+		},15+	},16+	{17+		"type": "function",18+		"function": map[string]any{19+			"name":        "write_file",20+			"description": "Create or replace a file in the current directory with the given contents",21+			"parameters": map[string]any{22+				"type": "object",23+				"properties": map[string]any{24+					"path":    map[string]any{"type": "string"},25+					"content": map[string]any{"type": "string"},26+				},27+				"required": []string{"path", "content"},28+			},29+		},30+	},31+	{32+		"type": "function",33+		"function": map[string]any{34+			"name":        "list_dir",35+			"description": "List the files and directories in the current directory",36+			"parameters": map[string]any{37+				"type": "object",38+				"properties": map[string]any{39+					"path": map[string]any{"type": "string"},40+				},41+			},42+		},43+	},44+}45+146 type Message struct {2-	Role    string `json:"role"`3-	Content string `json:"content"`47+	Role             string     `json:"role"`48+	Content          string     `json:"content"`49+	ReasoningContent string     `json:"reasoning_content,omitempty"`50+	ToolCalls        []ToolCall `json:"tool_calls,omitempty"`51+	ToolCallID       string     `json:"tool_call_id,omitempty"`452 }553 654 type chatRequest struct {7-	Model    string    `json:"model"`8-	Messages []Message `json:"messages"`55+	Model    string           `json:"model"`56+	Messages []Message        `json:"messages"`57+	Tools    []map[string]any `json:"tools,omitempty"`958 }
writing/pinocchio/v1/main.gowriting/pinocchio/v2/main.go

Then we introduce the inner loop that manages the tool calls and their executions:

@@ -12,11 +12,26 @@1212 		}1313 		messages = append(messages, Message{Role: "user", Content: text})1414 15-		response, err := chat(messages)16-		if err != nil {17-			panic(err)15+		for {16+			assistant, err := chat(messages, tools)17+			if err != nil {18+				panic(err)19+			}20+			messages = append(messages, assistant)21+22+			if len(assistant.ToolCalls) == 0 {23+				fmt.Println(assistant.Content)24+				break // the model stopped asking for tools: turn over25+			}26+			for _, call := range assistant.ToolCalls {27+				fmt.Printf("\n>> %s\n", call.Function.Name)28+				result := runTool(call)29+				messages = append(messages, Message{30+					Role:       "tool",31+					ToolCallID: call.ID,32+					Content:    result,33+				})34+			}1835 		}19-		messages = append(messages, response)20-		fmt.Println(response.Content)2136 	}2237 }
writing/pinocchio/v1/main.gowriting/pinocchio/v2/main.go

The code shows how "autonomous actions" work (it's just a loop!). One notable aspect is how the loop breaks: it happens once there are no more tool calls to execute. At that point the agent gives back control to the user.

The implementation of the tools is up to the agent and in Pinocchio's case that happens inside the runTool function:

// runTool executes a tool call and returns its output, or an error string// the model can read when the call fails.func runTool(call ToolCall) string {	switch call.Function.Name {	case "read_file":		var args struct {			Path string `json:"path"`		}		if err := json.Unmarshal([]byte(call.Function.Arguments), &args); err != nil {			return "error: " + err.Error()		}		b, err := os.ReadFile(args.Path)		if err != nil {			return "error: " + err.Error()		}		return string(b)	case "write_file":		var args struct {			Path    string `json:"path"`			Content string `json:"content"`		}		if err := json.Unmarshal([]byte(call.Function.Arguments), &args); err != nil {			return "error: " + err.Error()		}		if err := os.WriteFile(args.Path, []byte(args.Content), 0o644); err != nil {			return "error: " + err.Error()		}		return "wrote " + args.Path	case "list_dir":		var args struct {			Path string `json:"path"`		}		if err := json.Unmarshal([]byte(call.Function.Arguments), &args); err != nil {			return "error: " + err.Error()		}		if args.Path == "" {			args.Path = "."		}		entries, err := os.ReadDir(args.Path)		if err != nil {			return "error: " + err.Error()		}		var b strings.Builder		for _, e := range entries {			name := e.Name()			if e.IsDir() {				name += "/"			}			b.WriteString(name)			b.WriteString("\n")		}		return b.String()	default:		return "unknown tool: " + call.Function.Name	}}
writing/pinocchio/v2/main.go

Now you could follow the "use bash for everything" approach, meaning you pass only one tool called "bash" to the LLM, or you could go much more granular. Moreover, the implementation of each tools can follow different paths: you can do it "natively" using the stdlib of the language the agent is written in, you can fork into a subprocess and delegate to a different program (it's common for search where agents may use rg), you can use a MCP or anything you can think of.

For the sake of our conversation, the actual implementation of the tools we passed doesn't matter. The LLM output is somewhat of a "suggestion" but it can only be as good as the tool definitions it receives. The topic is too large for a comprehensive conversation so we'll write about it in a separate post of this series.

Let's now look at an example of what Pinocchio can do now that it has access to tools. In the working directory there's a Go program (greet.go) that greets people and defaults to world for empty names. To demonstrate that Pinocchio can actually do things we ask it questions it can only answer by leveraging tools:

> what does greet.go do?
>> list_dir
greet.go

>> read_file

It's a tiny program: greet falls back to "world" for an empty name,
then main prints the greeting for both "Ada" and "".

> change the empty-name fallback from "world" to "stranger"
>> write_file
wrote greet.go
Done. Empty names now greet as "stranger".

It works 🎉 but.. notice what never happened in the conversation: nobody asked you anything. Between Pinocchio deciding to call write_file and greet.go changing on disk there was no question, no confirmation: just the model's intent and your full permissions. That's where permissions model comes in.

Version 3: asking for permission

Real world coding agents approach permissions in very different ways. Here's a few common strategies:

  1. Rule/policy-based ask-approval: deterministic per-call allow|ask|deny engine, "ask" escalates to a human prompt with once/session/always persistence.
  2. OS-level sandboxing: real kernel boundary as the baseline.
  3. LLM-as-judge: hidden model call classifies tool-call risk.
  4. No-permission/delegation by design/YOLO: runs with full privileges. Yes some do this and it works for them!

For the sake of simplicity, Pinocchio implements a gated write_file which is a radically simple version of the rule-based ask-approval strategy.

Here's how it looks like:

@@ -132,6 +132,14 @@132132 	return decoded.Choices[0].Message, nil133133 }134134 135+// approve asks the human before runTool mutates anything on disk.136+func approve(action string) bool {137+	fmt.Printf("run %q? [y/N] ", action)138+	var answer string139+	fmt.Scanln(&answer)140+	return strings.TrimSpace(answer) == "y"141+}142+135143 // runTool executes a tool call and returns its output, or an error string136144 // the model can read when the call fails.137145 func runTool(call ToolCall) string {@@ -157,6 +165,9 @@157165 		if err := json.Unmarshal([]byte(call.Function.Arguments), &args); err != nil {158166 			return "error: " + err.Error()159167 		}168+		if !approve("write_file " + args.Path) {169+			return "the human said no. Find another way."170+		}160171 		if err := os.WriteFile(args.Path, []byte(args.Content), 0o644); err != nil {161172 			return "error: " + err.Error()162173 		}
writing/pinocchio/v2/main.gowriting/pinocchio/v3/main.go

and here's how it works:

> count files in this directory and show names

Here are the results:

**Total files: 5**

File names:
1. `go.mod` (root)
2. `v1/main.go`
3. `v2/main.go`
4. `v2/demo/greet.go`
5. `v3/main.go`

The directory also contains subdirectories `v1/`, `v2/`, and `v3/` (plus `v2/demo/`),
which contain these files.
> now write v4/main.go from v3

run "write_file v4/main.go"? [y/N]

It works 🎉 now Pinocchio asks for permission to write files!

Version 4: compaction

Interacting with LLMs is stateless. In fact, if you recall the core agent loop, it contains these lines:

messages = append(messages, Message{Role: "user", Content: text})

response, err := chat(messages)
if err != nil {
panic(err)
}
messages = append(messages, response)

We store the entire conversation so we can keep sending it to the LLM at every turn.

Now, all LLMs have a "natural" limit called context window (unit of measure: tokens). If you exceed this window, the LLM provider api will reject your call point-blank. It's clear that an agent needs a strategy to pass the history of the conversation to the LLM without exceeding this limit. This is what context compaction is about.

Real world agents can get quite creative with their compaction strategy. Just an example: the 10 agents in our short list use seven different strategies. But they all have in common one thing: summarization is a dedicated step (meaning there's a LLM call specialised into compacting the history of the conversation). What makes up so many different strategies is the when and how, we will go into details into an upcoming article.

Pinocchio does the simplest thing that works: when the conversation grows past a certain fixed budget, Pinocchio summarizes the first half with one extra model call and keeps the recent stuff. Here's the code:

@@ -14,6 +14,7 @@1414 const (1515 	apiBaseURL = "https://api.deepseek.com"1616 	model      = "deepseek-v4-flash"17+	budget     = 96 * 1024 // characters; tokens are roughly chars/41718 )1819 1920 type Message struct {@@ -203,6 +204,48 @@203204 	}204205 }205206 207+// size returns the conversation size in characters.208+func size(messages []Message) int {209+	n := 0210+	for _, m := range messages {211+		n += len(m.Content)212+	}213+	return n214+}215+216+// summarize compresses old messages into one assistant message with a217+// dedicated model call.218+func summarize(old []Message) Message {219+	b, err := json.Marshal(old)220+	if err != nil {221+		panic(err)222+	}223+	reply, err := chat([]Message{{224+		Role:    "user",225+		Content: "Summarize this conversation in ten bullet points:\n\n" + string(b),226+	}}, nil)227+	if err != nil {228+		panic(err)229+	}230+	reply.ReasoningContent = ""231+	return reply232+}233+234+// compact keeps the conversation under budget: once it grows past the limit235+// it summarizes the first half with one model call and keeps the recent half.236+func compact(messages []Message) []Message {237+	total, before := size(messages), len(messages)238+	if total < budget {239+		return messages240+	}241+	mid := before / 2242+	old, recent := messages[:mid], messages[mid:]243+	messages = append([]Message{summarize(old)}, recent...)244+	fmt.Printf(">> over budget (%d chars): summarized %d of %d messages into one (%d chars left)\n",245+		total, mid, before, size(messages))246+	return messages247+}248+206249 func main() {207250 	var messages []Message208251 	in := bufio.NewScanner(os.Stdin)@@ -218,6 +261,7 @@218261 		messages = append(messages, Message{Role: "user", Content: text})219262 220263 		for {264+			messages = compact(messages)221265 			assistant, err := chat(messages, tools)222266 			if err != nil {223267 				panic(err)
writing/pinocchio/v3/main.gowriting/pinocchio/v4/main.go

As you can see this is a simple and practical solution. Now let's see it in action:

go run v4/main.go
> let's review the project, read the code of v1, v2 and v3
>> list_dir
v1/main.go
v2/main.go
v3/main.go
v4/main.go

>> read_file
(...)
...
> and what's new in v4?
(...)
...   // tool outputs keep piling up, every read_file lives in the conversation
>> over budget (104,210 chars): summarized 26 of 52 messages into one (41,020 chars left)

> thanks, let's continue the review
assistant: Happy to. What should we look at next — the permission gate in
v3, or how v4 decides when to summarize?

The first 26 messages collapse into a ten-bullet summary while the recent half survives untouched, and the conversation carries on under the budget.

Version 5: a session file

Pinocchio has no storage. The entire conversation lives in an in-memory array of messages and has no chance of surviving a restart or a crash.

Real world coding agents may use a sqlite database or a slightly more elaborated solution but, for the sake of simplicity, Pinocchio will use a simple append-only file. Every message exchanged in the conversation gets appended to a file: one JSON document per line. We pass the session file as an argument at startup and replay it back into messages:

@@ -246,8 +246,45 @@246246 	return messages247247 }248248 249+// save appends a message to the session file, one JSON document per line.250+func save(file string, m Message) {251+	b, _ := json.Marshal(m)252+	f, _ := os.OpenFile(file, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)253+	defer f.Close()254+	f.Write(append(b, '\n'))255+}256+257+// loadSession replays the session file into messages. A torn last line258+// (the crash that killed us mid-write) simply fails to unmarshal and is259+// dropped: one lost message, not a lost file.260+func loadSession(file string) []Message {261+	b, err := os.ReadFile(file)262+	if err != nil {263+		return nil264+	}265+	var messages []Message266+	for _, line := range strings.Split(string(b), "\n") {267+		var m Message268+		if json.Unmarshal([]byte(line), &m) == nil {269+			messages = append(messages, m)270+		}271+	}272+	return messages273+}274+275+// appendMsg records m on disk, then appends it to the conversation.276+func appendMsg(file string, messages []Message, m Message) []Message {277+	save(file, m)278+	return append(messages, m)279+}280+249281 func main() {250-	var messages []Message282+	if len(os.Args) < 2 {283+		fmt.Fprintln(os.Stderr, "usage: pinocchio <session-file>")284+		os.Exit(1)285+	}286+	file := os.Args[1]287+	messages := loadSession(file)251288 	in := bufio.NewScanner(os.Stdin)252289 	for {253290 		fmt.Print("> ")@@ -258,7 +295,7 @@258295 		if text == "" {259296 			continue260297 		}261-		messages = append(messages, Message{Role: "user", Content: text})298+		messages = appendMsg(file, messages, Message{Role: "user", Content: text})262299 263300 		for {264301 			messages = compact(messages)@@ -266,7 +303,7 @@266303 			if err != nil {267304 				panic(err)268305 			}269-			messages = append(messages, assistant)306+			messages = appendMsg(file, messages, assistant)270307 271308 			if len(assistant.ToolCalls) == 0 {272309 				fmt.Println(assistant.Content)@@ -275,7 +312,7 @@275312 			for _, call := range assistant.ToolCalls {276313 				fmt.Printf("\n>> %s\n", call.Function.Name)277314 				result := runTool(call)278-				messages = append(messages, Message{315+				messages = appendMsg(file, messages, Message{279316 					Role:       "tool",280317 					ToolCallID: call.ID,281318 					Content:    result,
writing/pinocchio/v4/main.gowriting/pinocchio/v5/main.go

Let's see it in action:

go run v5/main.go pinocchio.jsonl
> fix the typo in greet.go: empty names should greet as "stranger"
>> list_dir
greet.go

>> read_file
package main

import "fmt"

func greet(name string) string {
	if name == "" {
		name = "world"
	}
	return fmt.Sprintf("Hello, %s!", name)
}

func main() {
	for _, n := range []string{"Ada", ""} {
		fmt.Println(greet(n))
	}
}

>> write_file
run "write_file greet.go"? [y/N] y
wrote greet.go
Done. Empty names now greet as "stranger".

# ctrl-c happens. terminals close. you come back tomorrow.
^C

go run v5/main.go pinocchio.jsonl
> what were we doing?
assistant: We were changing greet.go so empty names greet as "stranger"
instead of "world" — and we already wrote the fix before the session ended.

Pinocchio ain't real

Pinocchio isn't a real coding agent. It's been a fun didactic expedient to explain how the core functionality of an agent looks like. Of course we left a tons of things out by choice, here's a short list of the obvious things we will come back to throughout the series:

  • System prompts: we've debated including system prompts in this first article about Pinocchio but their role in coding agents has changed over the past year so we will have to dedicate a whole article to them.
  • Streaming: Pinocchio stares at you in silence until the full reply arrives. This is highly distressing as a user and of course no real world coding agent works this way.
  • Sub-agents: most coding agents have a way to delegate work to "copies" of themselves (often with a different system prompt, permission model).
  • Provider abstraction: While we love DeepSeek and rely on it for most of our work, a real world coding agent can't be this opinionated.
  • Extensibility: no plugins, no MCP, no way to teach Pinocchio a new trick without recompiling it.

In the next post, we'll apply the five-point framework to the shortlist of coding agents we introduced in the opening article of the series. The goal is to make a comparative analysis of real world agents to understand their relative strengths and weaknesses.