Build your first AI agent in Go, start to finish
A complete agent in standard-library Go, in a handful of short files. The message types, a client for a chat completions API, the loop, two tools confined to one directory, and a system prompt; run it offline from a recorded run, then live on your own files.
This page works on all four stages of the loop
- 1Build the request (this page)
- 2Call the model (this page)
- 3Run the tools (this page)
- 4Decide (this page)
This page builds a whole agent and runs it.
It answers questions about the files in one directory: you ask “which release added rate limiting?”, and it lists the files, reads the ones that look relevant, and answers from what they say.
Every piece is here, from the types that hold the conversation to the program’s main, and nothing comes from a framework or an SDK.
The task is deliberately safe. The agent’s two tools only read, and only inside the directory you give it, so you can point it at your own notes without worrying about what it might change.
It assumes you’ve read what an agent is and made a first model call, but nothing more.
Run it first
The program is firstagent in the companion code.
From the repository’s code directory, with no API key:
$ go run ./firstagent
Without a key, it replays a run recorded from a live model, xiaomi/mimo-v2.6-pro, on October 19, 2026, while the tools read the files of a small sample project for real.
This is what it prints:
question: Which release added rate limiting, and what is the default limit?
turn 1: list_files {}
turn 2: read_file {"path": "CHANGELOG.md"}
turn 2: read_file {"path": "config/defaults.yaml"}
3 model calls, 969 tokens in, 179 out, $0.0005
**Release:** v1.3.0 (2026-08-02) added per-client rate limiting, according to `CHANGELOG.md`. Each client is identified by IP address and the limit is set by `rate_limit.requests_per_minute`.
**Default limit:** 120 requests per minute (with a burst of 20), per `config/defaults.yaml`.
8 messages
Three calls to the model. On the first, it asked to see the files. On the second, it asked to read two of them at once. On the third, it had what it needed and answered, naming the file each fact came from. The whole run cost about five hundredths of a cent.
The rest of this page builds that program, one file at a time.
The files import only the standard library, so you can copy the firstagent directory into a module of your own and change it from there.
The types
Four types carry everything: a message, a tool call inside a message, a tool, and, in the next section, the client.
// Message is one entry in the conversation. Its JSON tags match the
// chat completions API, so the client sends and receives it as is.
type Message struct {
Role string `json:"role"` // system, user, assistant, tool
Content string `json:"content,omitzero"`
ToolCalls []ToolCall `json:"tool_calls,omitzero"`
ToolCallID string `json:"tool_call_id,omitzero"`
// Reasoning some models return, sent back unchanged.
Reasoning jsontext.Value `json:"reasoning_details,omitzero"`
}
// ToolCall is the model asking to run one tool.
type ToolCall struct {
ID string `json:"id"`
Type string `json:"type"` // always "function"
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"` // JSON, in a string
} `json:"function"`
}
// Tool is something the agent can do: a definition the model reads,
// and a Go function the model never sees.
type Tool struct {
Name string
Description string
Parameters jsontext.Value // a JSON Schema for the arguments
Run func(ctx context.Context, args string) (string, error)
}
Message has the same fields, with the same JSON names, as a message in the chat completions API, so the client can send the conversation as it is and decode the model’s reply straight into one.
That keeps this program short.
The companion code keeps its own types apart from the wire format instead, which lets one agent talk to providers whose formats differ; calling a model from Go shows that design.
Two fields need a word.
A tool call’s Arguments is a string that holds JSON, not a JSON object, so each tool decodes its own.
Reasoning holds the reasoning some models return with a reply; the model expects it back, unchanged, with the next request, and keeping it on the message does that without any extra code.
A Tool has two halves: the name, description, and parameter schema that the model reads, and a Go function, Run, that the model never sees.
The client
The client turns the conversation and the tools into one HTTP request, and the response into a reply:
// Chat sends the conversation and the tool definitions, and returns
// the model's reply. Each call gets two minutes at most.
func (c *Client) Chat(ctx context.Context, msgs []Message,
tools []Tool) (Reply, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
req := chatRequest{Model: c.Model, Messages: msgs}
for _, t := range tools {
var d toolDef
d.Type = "function"
d.Function.Name = t.Name
d.Function.Description = t.Description
d.Function.Parameters = t.Parameters
req.Tools = append(req.Tools, d)
}
body, err := json.Marshal(req)
if err != nil {
return Reply{}, fmt.Errorf("encode request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.BaseURL+"/chat/completions", bytes.NewReader(body))
if err != nil {
return Reply{}, fmt.Errorf("build request: %w", err)
}
httpReq.Header.Set("Authorization", "Bearer "+c.APIKey)
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient().Do(httpReq)
if err != nil {
return Reply{}, fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
return readReply(resp)
}
Each call gets a deadline of two minutes, so a request that never comes back can’t hang the agent, and Ctrl-C reaches the HTTP request through ctx.
The tool definitions go out with every request; the model has no memory of them between calls.
Reading the response is where most of the care goes, because the API can report failure in several ways:
// readReply decodes a response, turning every kind of failure the API
// can report into an error.
func readReply(resp *http.Response) (Reply, error) {
data, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
if err != nil {
return Reply{}, fmt.Errorf("read response: %w", err)
}
var r chatResponse
err = json.Unmarshal(data, &r)
switch {
case resp.StatusCode != http.StatusOK && r.Error != nil:
return Reply{}, fmt.Errorf("model API: %s: %s", resp.Status,
r.Error.Message)
case resp.StatusCode != http.StatusOK:
return Reply{}, fmt.Errorf("model API: %s", resp.Status)
case err != nil:
return Reply{}, fmt.Errorf("decode response: %w", err)
case r.Error != nil: // a 200 that carries an error
return Reply{}, fmt.Errorf("model API: %s", r.Error.Message)
case len(r.Choices) == 0:
return Reply{}, errors.New("model API: reply has no choices")
}
ch := r.Choices[0]
if s := string(ch.Message.Reasoning); s == "null" || s == "[]" {
ch.Message.Reasoning = nil // nothing worth sending back
}
return Reply{Message: ch.Message, FinishReason: ch.FinishReason,
Usage: r.Usage}, nil
}
A non-200 status, an error object inside a 200, and a reply with no choices all become errors. Common first errors shows what each looks like on the wire.
The loop
This is the agent:
// Run sends the conversation to the model, runs the tools it asks for,
// and repeats until the model answers or maxTurns runs out. It returns
// the answer and the whole conversation, and reports each step to log.
func Run(ctx context.Context, c *Client, tools []Tool, msgs []Message,
maxTurns int, log io.Writer) (string, []Message, error) {
var calls int
var spent Usage
defer func() { // what the run cost, however it ended
fmt.Fprintf(log, "%d model calls, %d tokens in, %d out, $%.4f\n",
calls, spent.PromptTokens, spent.CompletionTokens, spent.Cost)
}()
for turn := range maxTurns {
reply, err := c.Chat(ctx, msgs, tools)
if err != nil {
return "", msgs, fmt.Errorf("turn %d: %w", turn+1, err)
}
calls++
spent.PromptTokens += reply.Usage.PromptTokens
spent.CompletionTokens += reply.Usage.CompletionTokens
spent.Cost += reply.Usage.Cost
if reply.FinishReason == "length" {
return "", msgs, errCutOff
}
msgs = append(msgs, reply.Message) // the reply first, then results
if len(reply.Message.ToolCalls) == 0 {
return reply.Message.Content, msgs, nil // no tools: the answer
}
for _, call := range reply.Message.ToolCalls {
fmt.Fprintf(log, "turn %d: %s %s\n", turn+1,
call.Function.Name, call.Function.Arguments)
msgs = append(msgs, Message{
Role: "tool",
ToolCallID: call.ID,
Content: runTool(ctx, tools, call),
})
}
}
return "", msgs, ErrMaxTurns
}
Each pass is one turn: send the conversation, append the reply, and either stop or run the tools.
A reply with no tool calls is the answer.
A reply with tool calls gets one tool message for each call, tagged with the call’s ID, and the loop goes around again with everything so far.
The order of the appends matters. The model’s reply goes into the conversation before its tools run, so every tool result answers a call the conversation already holds; the API refuses a request where it doesn’t.
The loop stops in three ways: an answer, a reply cut off by finish_reason: length, which is never treated as an answer, or the turn limit.
The deferred line reports what the run cost however it ended.
The agent loop goes through each of these rules in more depth.
Running one call is a small function of its own:
// runTool runs one call and returns what to tell the model. A failure
// becomes text the model can read and act on, so the run goes on.
func runTool(ctx context.Context, tools []Tool, call ToolCall) string {
var names []string
for _, t := range tools {
if t.Name == call.Function.Name {
out, err := t.Run(ctx, call.Function.Arguments)
if err != nil {
return "Error: " + err.Error()
}
if out == "" {
return "(no output)"
}
return out
}
names = append(names, t.Name)
}
return fmt.Sprintf("Error: no tool named %q. The tools are: %s.",
call.Function.Name, strings.Join(names, ", "))
}
A tool that fails doesn’t stop the run. Its error goes back to the model as the tool’s result, worded so the model can do something about it, and an unknown tool name gets the list of real ones. Models misspell tool names and send arguments in the wrong shape more often than you’d expect, and most of them correct the call when the error says how.
The tools
// fileTools returns the agent's two tools, which read only from
// files. Give it os.Root's FS: a root refuses any path that leads
// outside its directory, through ".." or a symbolic link.
func fileTools(files fs.FS) []Tool {
return []Tool{{
Name: "list_files",
Description: "List every file in the directory, one path per line.",
Parameters: []byte(`{"type": "object", "properties": {}}`),
Run: func(context.Context, string) (string, error) {
return listFiles(files)
},
}, {
Name: "read_file",
Description: "Read one file. path is relative to the directory, " +
"as list_files prints it.",
Parameters: []byte(`{"type": "object", "properties": {"path":
{"type": "string"}}, "required": ["path"]}`),
Run: func(_ context.Context, args string) (string, error) {
var a struct {
Path string `json:"path"`
}
if err := json.Unmarshal([]byte(args), &a); err != nil {
return "", fmt.Errorf("arguments: %w", err)
}
return readFile(files, a.Path)
},
}}
}
Both tools read through an fs.FS, and main builds that from an os.Root, which refuses any path that leads out of the directory, whether through .. or a symbolic link.
That makes the confinement a property of the code: the model can ask for ../../.ssh/id_rsa, and the read simply fails.
The read is capped at 20,000 bytes, so one large file can’t fill the conversation.
The descriptions are written for the model, which reads nothing else about a tool.
read_file’s says that paths come from list_files, which steers the model to list before it reads.
Designing tools covers writing tools a model uses well.
The prompt and main
const systemPrompt = `You answer questions about the files in one
directory. Use list_files to see what is there and read_file to read
a file. Answer only from what the files say, name the files you used,
and if the files don't answer the question, say so.`
The system prompt names the job, the tools and when to use them, and how to answer, including what to do when the files don’t have the answer. That last line matters more than it looks: without it, a model asked something the files can’t answer is inclined to answer anyway, from its general knowledge. Messages, roles, and the system prompt has more on writing one.
// run sets up the client, the tools, and the conversation, and runs
// the agent.
func run(ctx context.Context, getenv func(string) string, dir, record,
question string, out io.Writer) error {
c := &Client{
BaseURL: "https://openrouter.ai/api/v1",
APIKey: getenv("OPENROUTER_API_KEY"),
Model: cmp.Or(getenv("OPENROUTER_MODEL"), defaultModel),
}
rec, err := setTransport(c, record, question)
if err != nil {
return err
}
files, closeFiles, err := openDir(dir)
if err != nil {
return err
}
defer closeFiles()
msgs := []Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: question},
}
fmt.Fprintf(out, "question: %s\n", question)
answer, msgs, err := Run(ctx, c, fileTools(files), msgs, 10, out)
if rec != nil {
err = errors.Join(err, rec.save(record))
}
if err != nil {
return err
}
fmt.Fprintf(out, "\n%s\n\n%d messages\n", answer, len(msgs))
return nil
}
run builds the client, picks how it reaches the network, opens the directory, and starts the conversation with the system prompt and the question.
With no key, setTransport gives the client a transport that answers from the recording; with -record, one that saves the run.
Because the switch happens at the HTTP layer, the agent’s code is identical online and offline, and the tests use the same idea with a fake server.
Run it on your own files
Set a key, as your first model call describes, and point the agent at a directory:
$ export OPENROUTER_API_KEY=sk-or-...
$ go run ./firstagent -dir ~/notes "What did I decide about the cache?"
OPENROUTER_MODEL picks another model, and choosing a model covers which.
-record run.json saves the run’s API calls, so you can read exactly what was sent and what came back.
What it leaves out
This agent is complete, but it is not ready to run unattended. Each of these is a page of its own:
- Retries. A 429 or a 503 ends the run here. Retries and observability
- A budget. It stops at ten turns, but nothing caps what it spends. Why the agent stopped
- Tests of the agent’s behavior. The program’s own tests use a fake server; testing what an agent decides is a different job. Testing an agent offline
- Long conversations. Every file it reads stays in the conversation, and a run over a big directory grows expensive. Context management
- Tools that change things. Writing files, running commands, or calling APIs needs a sandbox and, for some actions, a person’s approval. Prompt injection and sandboxing and human approval
- Streaming. It prints nothing until each reply is complete. Streaming replies