DEV Community

Cover image for How to Build an AI Agent in Go (From Scratch, No Framework)
Maksim Danilchenko
Maksim Danilchenko

Posted on • Originally published at danilchenko.dev

How to Build an AI Agent in Go (From Scratch, No Framework)

TL;DR

An "AI agent" is a loop: send a prompt to a model, let it ask for a tool, run the tool, feed the result back, repeat until it stops. You can write that loop in Go with the standard library and about 120 lines of code. No LangChain, no SDK, no google.golang.org/adk. This tutorial builds a working agent that can read files and list directories on your machine, driven by the Anthropic Messages API. I'll show the full source, the exact JSON the API expects, the terminal output from a real run, and the two mistakes that cost me an hour the first time.

Why write this in Go at all?

Python owns the agent tutorials, and for good reason — every framework ships a Python client first. But Go has quietly become a strong fit for the runtime side of agents. A Hacker News thread from earlier this year argued the case: one build system, one formatter, static types, and real concurrency without the footguns. When your agent has to fan out ten tool calls, handle timeouts, and ship as a single static binary, that counts for more than how many frameworks exist.

I spent an afternoon porting a toy Python agent to Go, and the thing I kept noticing was how little I missed the framework. The whole "agent" abstraction collapses into a for loop and a switch statement once you see the message flow. That is the point of this post: strip away the layers so you understand what an agent actually is, then decide whether you want a framework on top.

Here's what we'll build: a working file-reading agent in about 120 lines of Go, with zero third-party dependencies and two tools.

The mental model: an agent is a loop

Strip away the marketing and an LLM agent is four steps in a cycle:

  1. Send the conversation (plus a list of tools the model is allowed to call) to the API.
  2. The model replies. It either answers in text, or it asks to call a tool with some arguments.
  3. If it asked for a tool, your code runs that tool and captures the result.
  4. You append the result to the conversation and go back to step 1.

The loop ends when the model responds with plain text and no tool request. The API signals this with stop_reason: "end_turn", and that is the whole pattern. Memory, planning, and "ReAct" are all variations on the same cycle. If you understand the loop, you understand agents.

Setup

You need Go 1.21+ and an API key. Grab one from the Anthropic Console (the tool-use format is cleanest there; I'll note the OpenAI equivalent at the end). Export it:

export ANTHROPIC_API_KEY="sk-ant-..."
mkdir go-agent && cd go-agent
go mod init go-agent
Enter fullscreen mode Exit fullscreen mode

No dependencies to go get. We use net/http and encoding/json from the standard library and nothing else.

Step 1: Talk to the model

Before we add tools, let's make one plain request so the wiring is obvious. The Messages API wants a POST to https://api.anthropic.com/v1/messages with three headers and a JSON body. Here are the types:

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
)

type Tool struct {
    Name        string         `json:"name"`
    Description string         `json:"description"`
    InputSchema map[string]any `json:"input_schema"`
}

type ContentBlock struct {
    Type string `json:"type"`
    // text block
    Text string `json:"text,omitempty"`
    // tool_use block (from the model)
    ID    string          `json:"id,omitempty"`
    Name  string          `json:"name,omitempty"`
    Input json.RawMessage `json:"input,omitempty"`
    // tool_result block (from us)
    ToolUseID string `json:"tool_use_id,omitempty"`
    Content   string `json:"content,omitempty"`
}

type Message struct {
    Role    string         `json:"role"`
    Content []ContentBlock `json:"content"`
}

type Request struct {
    Model     string    `json:"model"`
    MaxTokens int       `json:"max_tokens"`
    Messages  []Message `json:"messages"`
    Tools     []Tool    `json:"tools,omitempty"`
}

type Response struct {
    Content    []ContentBlock `json:"content"`
    StopReason string         `json:"stop_reason"`
}
Enter fullscreen mode Exit fullscreen mode

The one struct worth staring at is ContentBlock. The API models every piece of a turn as a typed block (a text block, a tool_use block, a tool_result block), and omitempty lets one Go struct serialize as any of them. The model's input arrives as arbitrary JSON, so I keep it as json.RawMessage and decode it later per tool.

Now the call itself:

func callClaude(msgs []Message, tools []Tool) (Response, error) {
    body, _ := json.Marshal(Request{
        Model:     "claude-sonnet-5",
        MaxTokens: 1024,
        Messages:  msgs,
        Tools:     tools,
    })

    req, _ := http.NewRequest("POST",
        "https://api.anthropic.com/v1/messages",
        bytes.NewReader(body))
    req.Header.Set("x-api-key", os.Getenv("ANTHROPIC_API_KEY"))
    req.Header.Set("anthropic-version", "2023-06-01")
    req.Header.Set("content-type", "application/json")

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return Response{}, err
    }
    defer resp.Body.Close()

    data, _ := io.ReadAll(resp.Body)
    if resp.StatusCode != 200 {
        return Response{}, fmt.Errorf("api %d: %s", resp.StatusCode, data)
    }

    var out Response
    return out, json.Unmarshal(data, &out)
}
Enter fullscreen mode Exit fullscreen mode

The anthropic-version header is not optional. Leave it out and you get a 400 with a message that does not obviously point at the missing header. That was gotcha number one for me. The Messages API reference documents the current version string.

Step 2: Define the tools

A tool is a name, a description, and a JSON Schema for its arguments. The description is doing real work here: the model reads it to decide when to call the tool, so write it the way you'd brief a new teammate who has to figure out on their own when to reach for it.

var readFileTool = Tool{
    Name:        "read_file",
    Description: "Read a file at a relative path and return its contents as text.",
    InputSchema: map[string]any{
        "type": "object",
        "properties": map[string]any{
            "path": map[string]any{
                "type":        "string",
                "description": "Relative path, e.g. main.go or docs/readme.md",
            },
        },
        "required": []string{"path"},
    },
}

var listFilesTool = Tool{
    Name:        "list_files",
    Description: "List the files and folders in a directory. Defaults to the current directory.",
    InputSchema: map[string]any{
        "type": "object",
        "properties": map[string]any{
            "dir": map[string]any{
                "type":        "string",
                "description": "Directory to list. Empty means current directory.",
            },
        },
    },
}
Enter fullscreen mode Exit fullscreen mode

And the dispatcher that actually runs a tool when the model asks:

func executeTool(name string, input json.RawMessage) string {
    switch name {
    case "read_file":
        var args struct {
            Path string `json:"path"`
        }
        json.Unmarshal(input, &args)
        data, err := os.ReadFile(args.Path)
        if err != nil {
            return "error: " + err.Error()
        }
        return string(data)

    case "list_files":
        var args struct {
            Dir string `json:"dir"`
        }
        json.Unmarshal(input, &args)
        if args.Dir == "" {
            args.Dir = "."
        }
        entries, err := os.ReadDir(args.Dir)
        if err != nil {
            return "error: " + err.Error()
        }
        names := make([]string, 0, len(entries))
        for _, e := range entries {
            names = append(names, e.Name())
        }
        return strings.Join(names, "\n")
    }
    return "unknown tool: " + name
}
Enter fullscreen mode Exit fullscreen mode

Notice I return the error string to the model instead of crashing. That's deliberate. A good agent tool hands failures back as text so the model can recover — try a different path, ask the user, give up gracefully. If your tool panics, the loop dies and the model never gets a chance to react.

Step 3: The agent loop

This is the whole payoff — the piece that turns a chat call into an agent. Add bufio and strings to the imports, then:

func main() {
    tools := []Tool{readFileTool, listFilesTool}
    scanner := bufio.NewScanner(os.Stdin)
    var msgs []Message

    fmt.Println("Go agent ready. Ask about files in this folder (Ctrl-D to quit).")
    for {
        fmt.Print("\nyou> ")
        if !scanner.Scan() {
            break
        }
        text := strings.TrimSpace(scanner.Text())
        if text == "" {
            continue
        }
        msgs = append(msgs, Message{
            Role:    "user",
            Content: []ContentBlock{{Type: "text", Text: text}},
        })

        // Inner loop: keep running tools until the model is done.
        for {
            resp, err := callClaude(msgs, tools)
            if err != nil {
                fmt.Println("error:", err)
                break
            }
            // Echo the assistant turn back into history verbatim.
            msgs = append(msgs, Message{Role: "assistant", Content: resp.Content})

            var results []ContentBlock
            for _, block := range resp.Content {
                switch block.Type {
                case "text":
                    fmt.Println("\nclaude>", block.Text)
                case "tool_use":
                    fmt.Printf("  [tool] %s(%s)\n", block.Name, block.Input)
                    out := executeTool(block.Name, block.Input)
                    results = append(results, ContentBlock{
                        Type:      "tool_result",
                        ToolUseID: block.ID,
                        Content:   out,
                    })
                }
            }

            if len(results) == 0 {
                break // stop_reason was end_turn — the model answered.
            }
            msgs = append(msgs, Message{Role: "user", Content: results})
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Two loops, nested. The outer loop is the human conversation. The inner loop is the agent thinking: it keeps calling tools and feeding results back until the model stops asking. When there are no tool_use blocks in a response, results is empty and we break out to wait for the next human message.

The line that bit me, gotcha number two, is msgs = append(msgs, Message{Role: "assistant", Content: resp.Content}). You must echo the assistant's tool_use block back into the history before you send the matching tool_result. Skip it, and the API rejects your next request because a tool_result with no preceding tool_use is malformed. Keeping the raw resp.Content (including tool_use blocks as json.RawMessage) is the simplest way to get this right.

Running it

Build and run:

go build -o agent . && ./agent
Enter fullscreen mode Exit fullscreen mode

Here's a real session against a folder holding this project's Go files:

Go agent ready. Ask about files in this folder (Ctrl-D to quit).

you> what go files are here and what does main.go do?

  [tool] list_files({"dir":""})
  [tool] read_file({"path":"main.go"})

claude> This folder has three Go files: main.go, tools.go, and go.mod.
main.go is a command-line AI agent. It reads your input, sends it to the
Claude Messages API with two tools (read_file and list_files), runs any
tool the model requests, and loops until the model gives a final answer.
The core is a nested loop in main() that keeps executing tools until
stop_reason is end_turn.

you> which file is the biggest?

  [tool] list_files({"dir":""})
  [tool] read_file({"path":"main.go"})
  [tool] read_file({"path":"tools.go"})

claude> main.go is the largest at roughly 120 lines; tools.go is about 40.
Enter fullscreen mode Exit fullscreen mode

The model chained two tool calls in the first turn. It listed the directory, then decided to read main.go, all before writing a single word of the answer. You did not orchestrate that ordering. The loop simply kept handing control back until the model decided it had enough to answer.

When you actually want a framework

Building from scratch is the right way to learn, and honestly it's fine for small production agents too. But once you need streaming, retries with backoff, session persistence, or multi-agent handoff, a framework earns its place. Here are the main Go options today:

Option What it is Reach for it when
Standard library (this post) Raw HTTP + your own loop Learning, full control, single static binary, minimal surface area
Anthropic Go SDK Official typed client You want typed requests/streaming for one provider without hand-rolling JSON
Google ADK for Go Full agent framework: sessions, runners, tools Multi-agent orchestration, Gemini-first stacks
Eino ByteDance's composable orchestration graph Complex pipelines with branching and fan-out
langchaingo LangChain-style multi-provider abstraction You want one interface across OpenAI, Anthropic, and local models

My honest take: start with the standard library, add the official SDK when JSON marshaling gets tedious, and only pull in a full framework when you feel real pain from the missing pieces. Most agents I've seen reach for a framework far too early and end up fighting its abstractions instead of shipping.

If you're coming from the editor side of the agent world, the Agent Client Protocol covers how these loops plug into IDEs, and Claude Code Subagents shows the same tool-calling idea running inside a production coding agent. For the concurrency question specifically (why Go's model helps when tools run in parallel), the Rust vs Go comparison digs into the tradeoffs.

The parts I deliberately left out

This agent is real but minimal. Three things you'd add before trusting it with anything important:

  • Guardrails on tools. My read_file will happily read /etc/passwd if the model asks. In production you sanitize paths and sandbox the filesystem. I wrote up four ways this goes wrong in AI agent guardrails.
  • A turn limit. The inner loop trusts the model to eventually stop. A confused model can ping-pong tools forever. Add a counter and bail after, say, 10 iterations.
  • Streaming. We wait for the full response before printing. For a snappy CLI you'd switch to the streaming endpoint and print tokens as they arrive — Go's bufio.Scanner over the SSE body handles this cleanly.

None of these change the shape of the loop; they just harden it around the edges.

FAQ

Is Go good for building AI agents?

Yes, for the runtime. Go gives you static binaries, real concurrency for parallel tool calls, and strong typing around the JSON the API speaks. The tradeoff is fewer frameworks than Python — but as this post shows, you often don't need one. If your agent is mostly orchestration and I/O rather than data science, Go is a strong pick.

Do I need a framework like LangChain to build an agent in Go?

No. An agent is a loop over a chat API with tool calling, and the standard library covers it in ~120 lines. Frameworks add value for streaming, retries, session state, and multi-agent coordination, but they are optional. Start without one so you understand the mechanics.

How does an AI agent work under the hood?

It's a cycle: send the conversation plus a tool list to the model; the model either answers or requests a tool call; your code runs the tool and appends the result; repeat until the model answers with no tool request. The API's stop_reason field tells you when the loop is done.

Can I use OpenAI instead of Anthropic?

Yes. The shape is identical, only the field names differ. With the OpenAI API you POST to /v1/chat/completions, tools live under a tools array with a function wrapper, and the model returns tool_calls instead of tool_use blocks. Swap the request/response structs and the executeTool dispatcher stays exactly the same.

What model should I use for a Go agent?

Any model with tool-calling support works. I used claude-sonnet-5 because it's fast and reliable at deciding when to call a tool. For cheaper runs, a smaller model like Haiku handles simple tool routing well; for hard multi-step tasks, a frontier model chains tools more reliably.

Sources

Bottom line

The framework tutorials make agents look like a stack of abstractions you have to learn first. Really an agent is just a loop that hands control back and forth between a model and your tools, and Go writes that loop about as cleanly as any language I've tried. Build the 120-line version first. Once you can see the message flow in your head, you'll know exactly which framework feature you're missing — and, more often than not, that you don't need one yet.

Top comments (0)