agent.go
The bundle

Read on this page. Or run on your machine.

The companion zip ships a complete, self-contained Go module with every example below. Plain Go 1.22, zero external dependencies, eighteen unit tests, and two API-key-free LLM stand-ins so every go run works offline.

30 KBcompressed
20 files · 1,832 LOCcore + tests + examples
18 / 18tests · race-clean
0 depsstdlib only
# 1. unzip and enter the project $ unzip agentgo-examples.zip && cd agentgo-examples # 2. verify the build & tests are clean $ go vet ./... $ go build ./... $ go test ./... ok github.com/agentgo/examples/internal/agent 1.43s ok github.com/agentgo/examples/internal/llm 2.55s ok github.com/agentgo/examples/internal/tools 2.06s # 3. run any example — no API key required $ go run ./cmd/hello == Goal == What's the weather in Berlin, and what is 41 + 1? == Result == Final answer: · get_weather={"city":"Berlin","temperature":0,"unit":"C",...} · add_numbers={"sum":42,"expr":"41 + 1 = 42"} # 4. or fire all eight in sequence $ make run-all
Example 01 ~120 LOC · anthropic-sdk-go

Hello, agent — a complete ReAct loop with two tools.

The simplest agent that's still legitimately useful. Two tools — get_weather and add_numbers — wired into a ReAct loop. Read it once and you've read every agent loop in production.

cmd/hello/main.go go run ./cmd/hello
package main

import (
    "context"
    "encoding/json"
    "fmt"
    "log"
    "os"

    "github.com/anthropics/anthropic-sdk-go"
    "github.com/anthropics/anthropic-sdk-go/option"
)

type WeatherArgs struct {
    City string `json:"city"`
}

type AddArgs struct {
    A, B float64 `json:"a","b"`
}

var tools = []anthropic.ToolUnionParam{
    anthropic.ToolParam{
        Name:        "get_weather",
        Description: anthropic.String("Returns the current weather for a city."),
        InputSchema: schema(WeatherArgs{}),
    }.ToParam(),
    anthropic.ToolParam{
        Name:        "add_numbers",
        Description: anthropic.String("Adds two numbers and returns the sum."),
        InputSchema: schema(AddArgs{}),
    }.ToParam(),
}

func runTool(name string, raw json.RawMessage) string {
    switch name {
    case "get_weather":
        var a WeatherArgs; _ = json.Unmarshal(raw, &a)
        return fmt.Sprintf("%s: 18°C, partly cloudy", a.City)
    case "add_numbers":
        var a AddArgs; _ = json.Unmarshal(raw, &a)
        return fmt.Sprintf("%v", a.A+a.B)
    }
    return "unknown tool"
}

func main() {
    ctx := context.Background()
    client := anthropic.NewClient(option.WithAPIKey(os.Getenv("ANTHROPIC_API_KEY")))

    msgs := []anthropic.MessageParam{
        anthropic.NewUserMessage(anthropic.NewTextBlock(
            "What's the weather in Berlin, and what is 41 + 1?")),
    }

    for turn := 0; turn < 10; turn++ {
        resp, err := client.Messages.New(ctx, anthropic.MessageNewParams{
            Model:     anthropic.ModelClaudeOpus4_7,
            MaxTokens: 1024,
            Tools:     tools,
            Messages:  msgs,
        })
        if err != nil { log.Fatal(err) }

        if resp.StopReason == anthropic.StopReasonEndTurn {
            fmt.Println(resp.Content[0].AsResponseText().Text)
            return
        }

        msgs = append(msgs, resp.ToParam())
        var results []anthropic.ContentBlockParamUnion
        for _, block := range resp.Content {
            if tu := block.AsResponseToolUse(); tu.Type != "" {
                out := runTool(tu.Name, tu.Input)
                results = append(results, anthropic.NewToolResultBlock(tu.ID, out, false))
            }
        }
        msgs = append(msgs, anthropic.NewUserMessage(results...))
    }
}

Run it

export ANTHROPIC_API_KEY=...
go run ./cmd/hello

Watch it

Set slog to debug — you'll see two tool calls and one text turn before the loop exits.

Modify it

Add a third tool. Add a goroutine to run tool calls in parallel. Swap the model for Haiku 4.5.

Example 02 ~150 LOC · stdlib + reflect

Typed tool registry — JSON schema from struct tags.

Hand-rolling JSON schemas drifts. Generate them from the Go struct that defines the tool's argument shape — using github.com/invopop/jsonschema or stdlib reflection. The compiler catches every drift between what the model emits and what your code accepts.

internal/agent/registry.go
package agent

import (
    "context"
    "encoding/json"
    "fmt"

    "github.com/invopop/jsonschema"
)

type ToolHandler struct {
    Name        string
    Description string
    Schema      json.RawMessage
    Invoke      func(context.Context, json.RawMessage) (any, error)
}

type Registry struct {
    tools map[string]ToolHandler
}

func New() *Registry { return &Registry{tools: map[string]ToolHandler{}} }

// Register binds a typed function as a tool. The schema is derived once.
func Register[A any](r *Registry, name, desc string,
    fn func(context.Context, A) (any, error)) {

    var zero A
    raw, _ := json.Marshal(jsonschema.Reflect(&zero))

    r.tools[name] = ToolHandler{
        Name:        name,
        Description: desc,
        Schema:      raw,
        Invoke: func(ctx context.Context, b json.RawMessage) (any, error) {
            var a A
            if err := json.Unmarshal(b, &a); err != nil {
                return nil, fmt.Errorf("args for %s: %w", name, err)
            }
            return fn(ctx, a)
        },
    }
}

func (r *Registry) Dispatch(ctx context.Context, c ToolCall) (any, error) {
    h, ok := r.tools[c.Name]
    if !ok { return nil, fmt.Errorf("unknown tool %q", c.Name) }
    return h.Invoke(ctx, c.Arguments)
}
// Usage — three lines per tool, fully typed end to end.
r := agent.New()

agent.Register(r, "get_weather", "Returns current weather for a city.",
    func(ctx context.Context, a struct{ City string `json:"city" jsonschema:"required"` }) (any, error) {
        return weather.Lookup(ctx, a.City)
    })

agent.Register(r, "search_flights", "Search flights between two cities.",
    func(ctx context.Context, a flights.Query) (any, error) {
        return flights.Search(ctx, a)
    })
Example 03 ~180 LOC · net/http + SSE

Streaming SSE — model output to a browser.

A net/http handler that pipes Anthropic stream events to the browser as Server-Sent Events. Same shape works for OpenAI and Gemini — just swap the producer. No frameworks, no websockets, no third-party SSE library.

cmd/chat/main.go
func handleChat(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    w.Header().Set("Connection", "keep-alive")
    flusher, _ := w.(http.Flusher)

    prompt := r.URL.Query().Get("q")
    stream := client.Messages.NewStreaming(r.Context(),
        anthropic.MessageNewParams{
            Model:     anthropic.ModelClaudeSonnet4_6,
            MaxTokens: 2048,
            Messages:  []anthropic.MessageParam{
                anthropic.NewUserMessage(anthropic.NewTextBlock(prompt)),
            },
        })
    defer stream.Close()

    for stream.Next() {
        ev := stream.Current()
        if d := ev.AsContentBlockDelta(); d.Type != "" {
            fmt.Fprintf(w, "event: text\ndata: %s\n\n",
                d.Delta.AsTextDelta().Text)
            flusher.Flush()
        }
    }
    fmt.Fprint(w, "event: done\ndata: bye\n\n")
}
web/index.html · 24 lines of JS
<textarea id="q"></textarea>
<button id="go">Send</button>
<pre id="out"></pre>

<script>
go.onclick = () => {
  out.textContent = "";
  const es = new EventSource(
    "/chat?q=" + encodeURIComponent(q.value));
  es.addEventListener("text", e => {
    out.textContent += e.data;
  });
  es.addEventListener("done", () => es.close());
};
</script>
Example 04 ~220 LOC · pgvector + openai-go

RAG ingest — chunk, embed, store, query.

The boring backbone of every "chat with my docs" feature. Ingest is a pipeline; query is two lines. Postgres + pgvector is the right default — same database your app already uses, no new infrastructure.

internal/rag/ingest.go
package rag

import (
    "context"
    "strings"

    "github.com/jackc/pgx/v5/pgxpool"
    "github.com/openai/openai-go"
    "github.com/pgvector/pgvector-go"
)

type Chunk struct {
    Source string
    Body   string
}

// Ingest splits a document, embeds each chunk, and inserts into pgvector.
func Ingest(ctx context.Context, db *pgxpool.Pool,
    oa *openai.Client, source, doc string) error {

    chunks := splitMarkdown(doc, 800) // 800-token soft target

    for _, c := range chunks {
        resp, err := oa.Embeddings.New(ctx, openai.EmbeddingNewParams{
            Model: openai.EmbeddingModelTextEmbedding3Small,
            Input: openai.EmbeddingNewParamsInputUnion{
                OfString: openai.String(c),
            },
        })
        if err != nil { return err }

        vec := pgvector.NewVector(f32(resp.Data[0].Embedding))
        _, err = db.Exec(ctx, `
            INSERT INTO chunks (source, body, embedding) VALUES ($1, $2, $3)`,
            source, c, vec)
        if err != nil { return err }
    }
    return nil
}

// Query returns the top-K most relevant chunks for the question.
func Query(ctx context.Context, db *pgxpool.Pool,
    oa *openai.Client, q string, k int) ([]Chunk, error) {

    e, err := oa.Embeddings.New(ctx, openai.EmbeddingNewParams{
        Model: openai.EmbeddingModelTextEmbedding3Small,
        Input: openai.EmbeddingNewParamsInputUnion{OfString: openai.String(q)},
    })
    if err != nil { return nil, err }
    qv := pgvector.NewVector(f32(e.Data[0].Embedding))

    rows, err := db.Query(ctx, `
        SELECT source, body FROM chunks
        ORDER BY embedding <=> $1 LIMIT $2`, qv, k)
    if err != nil { return nil, err }
    defer rows.Close()

    var out []Chunk
    for rows.Next() {
        var c Chunk
        if err := rows.Scan(&c.Source, &c.Body); err != nil { return nil, err }
        out = append(out, c)
    }
    return out, nil
}
-- migrations/0001_chunks.sql
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE chunks (
    id        BIGSERIAL PRIMARY KEY,
    source    TEXT NOT NULL,
    body      TEXT NOT NULL,
    embedding VECTOR(1536) NOT NULL,
    created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops);
Example 05 ~280 LOC · the canonical four-tool agent

A coding agent that edits its own repo.

Four tools — read_file, write_file, bash, run_tests — give the model enough surface area to do non-trivial code edits. This is roughly the inner loop of Claude Code itself, distilled to the smallest version that's still useful.

The four-tool design

  • read_file(path) — returns the file contents and the line numbers. Capped at 2000 lines per call.
  • write_file(path, content) — replaces or creates a file. Always atomic via tempfile + rename.
  • bash(command, timeout) — runs a shell command in a sandboxed working directory. Returns stdout, stderr, exit code.
  • run_tests(package) — runs go test ./pkg/... and returns a parsed pass/fail summary. The "did I break it" check.

Sandboxing matters

The bash tool runs every command with the working directory rooted to $PROJECT_ROOT. The agent can't cd /, can't curl arbitrary urls, can't write outside the project. Run it in a container in production. The blast radius of an LLM with a shell is real.

tools/bash.go
type BashArgs struct {
    Command string `json:"command" jsonschema:"required"`
    TimeoutSec int `json:"timeout_sec,omitempty"`
}

type BashResult struct {
    ExitCode int    `json:"exit_code"`
    Stdout   string `json:"stdout"`
    Stderr   string `json:"stderr"`
    Truncated bool  `json:"truncated"`
}

func Bash(root string) agent.Tool[BashArgs] {
    return agent.Tool[BashArgs]{
        Name:        "bash",
        Description: "Run a shell command in the project sandbox.",
        Run: func(ctx context.Context, a BashArgs) (any, error) {
            to := 30; if a.TimeoutSec > 0 { to = a.TimeoutSec }
            tctx, cancel := context.WithTimeout(ctx,
                time.Duration(to)*time.Second)
            defer cancel()

            cmd := exec.CommandContext(tctx, "bash", "-c", a.Command)
            cmd.Dir = root
            cmd.Env = scrubbedEnv()
            var o, e bytes.Buffer
            cmd.Stdout, cmd.Stderr = cap(&o, 64_000), cap(&e, 64_000)
            err := cmd.Run()
            return BashResult{
                ExitCode:  cmd.ProcessState.ExitCode(),
                Stdout:    o.String(),
                Stderr:    e.String(),
                Truncated: o.Len() >= 64_000,
            }, err
        },
    }
}
// System prompt — terse. Models do better with constraints than with permission.
const sys = `You are a Go engineer with access to four tools: read_file, write_file, bash, run_tests.

Workflow:
1. Use read_file to understand the relevant code BEFORE changing anything.
2. Make the smallest change that satisfies the request.
3. After every write, run "go build ./..." with bash. Fix any errors.
4. Before declaring done, call run_tests on the affected package.

Constraints:
- Never write outside the project root.
- Never disable existing tests to make them pass.
- Prefer editing existing files to creating new ones.`
Example 06 ~340 LOC · orchestrator + workers · errgroup

A research team — orchestrator dispatches parallel workers.

The orchestrator is itself an agent — its only tool is dispatch, which spawns a worker. Workers run ReAct in their own goroutines with their own context windows. Modern providers' parallel tool-use lets the orchestrator fan out N workers in a single turn.

cmd/research/orchestrator.go
type DispatchArgs struct {
    Worker      string `json:"worker" jsonschema:"enum=researcher,enum=fact_checker,enum=writer"`
    Instruction string `json:"instruction"`
}

func DispatchTool(workers map[string]*Agent) agent.Tool[DispatchArgs] {
    return agent.Tool[DispatchArgs]{
        Name:        "dispatch",
        Description: "Spawn a worker agent. Workers run in parallel.",
        Run: func(ctx context.Context, a DispatchArgs) (any, error) {
            w, ok := workers[a.Worker]
            if !ok { return nil, fmt.Errorf("unknown worker %q", a.Worker) }
            // Each worker has its own short-lived context window — important.
            // The orchestrator never sees the worker's intermediate tool calls.
            return w.Run(ctx, a.Instruction)
        },
    }
}

func RunTeam(ctx context.Context, topic string) (string, error) {
    workers := map[string]*Agent{
        "researcher":   researcher(),   // tools: web_search, fetch_url
        "fact_checker": factChecker(),  // tools: web_search
        "writer":       writer(),       // no tools — pure synthesis
    }

    orch := &Agent{
        Model:        opus,
        SystemPrompt: orchSystemPrompt,
        Tools:        agent.RegistryWith(DispatchTool(workers)),
        MaxTurns:     8,
    }
    return orch.Run(ctx, "Research the following topic and write a report: "+topic)
}

The crucial design choice: workers don't share context with the orchestrator. The orchestrator decides what each worker is asked, sees only their final outputs, and synthesizes. Workers can search the web freely without polluting the orchestrator's small, expensive context window.

Example 07 ~190 LOC · MCP client (Model Context Protocol)

Speak MCP — connect to any MCP server as tools.

The Model Context Protocol gives your agent access to a growing ecosystem of pre-built tool servers — filesystem, GitHub, Notion, Slack, Postgres. The Go SDK ships an MCP client; bridging an MCP server's tools into your registry is a 30-line adapter.

internal/mcp/bridge.go
import "github.com/modelcontextprotocol/go-sdk/mcp"

// Mount registers every tool exposed by a remote MCP server into the local registry.
func Mount(ctx context.Context, r *agent.Registry, cmd ...string) error {
    cli := mcp.NewClient(&mcp.Implementation{
        Name:    "agent.go",
        Version: "0.1",
    }, nil)
    tx := mcp.NewCommandTransport(exec.Command(cmd[0], cmd[1:]...))
    sess, err := cli.Connect(ctx, tx, nil)
    if err != nil { return err }

    tools, err := sess.ListTools(ctx, &mcp.ListToolsParams{})
    if err != nil { return err }

    for _, t := range tools.Tools {
        t := t
        r.RawRegister(t.Name, t.Description, t.InputSchema,
            func(ctx context.Context, raw json.RawMessage) (any, error) {
                res, err := sess.CallTool(ctx, &mcp.CallToolParams{
                    Name:      t.Name,
                    Arguments: raw,
                })
                if err != nil { return nil, err }
                return res.Content, nil
            })
    }
    return nil
}
// Now any MCP server in the wild becomes part of the agent's tool surface.
mcp.Mount(ctx, registry, "npx", "-y", "@modelcontextprotocol/server-filesystem", "/data")
mcp.Mount(ctx, registry, "uvx", "mcp-server-github")
mcp.Mount(ctx, registry, "go", "run", "./cmd/mcp-postgres")
Example 08 ~410 LOC · OTel · slog · circuit breaker

From demo to service — the production wrapper.

Same single-loop agent, wrapped in the layers you'll wish you had when something goes wrong at 3am. OTel spans for every turn, structured slog with run IDs, exponential backoff with jitter, a circuit breaker, and a per-run cost cap. Each layer is independent — keep what you need, drop what you don't.

internal/agent/instrumented.go
type Instrumented struct {
    Inner   *Agent
    Tracer  trace.Tracer
    Budget  CostBudget
    Breaker *circuit.Breaker
}

func (i *Instrumented) Run(ctx context.Context, goal string) (string, error) {
    runID := uuid.NewString()
    log := slog.With("run_id", runID, "goal", trunc(goal, 120))
    ctx = slogctx.NewCtx(ctx, log)

    ctx, span := i.Tracer.Start(ctx, "agent.Run",
        trace.WithAttributes(
            attribute.String("agent.run_id", runID),
            attribute.Int("agent.max_turns", i.Inner.MaxTurns),
        ))
    defer span.End()

    if !i.Breaker.Allow() {
        return "", errors.New("agent: provider circuit open")
    }

    ctx = withCostMeter(ctx, i.Budget)
    out, err := i.Inner.Run(ctx, goal)
    switch {
    case errors.Is(err, ErrBudgetExceeded):
        log.Warn("agent ran over budget", "limit_usd", i.Budget.USD)
        span.SetStatus(codes.Error, "budget")
    case err != nil:
        i.Breaker.Failure()
        span.RecordError(err)
    default:
        i.Breaker.Success()
        log.Info("agent done",
            "turns", turnCount(ctx),
            "input_tokens", inToks(ctx),
            "output_tokens", outToks(ctx),
            "cost_usd", costNow(ctx))
    }
    return out, err
}

Tracing

One span per agent run, child spans per turn, child-of-child spans per tool call. The trace is the debugger.

Cost meter

Token counts × per-model rates, accumulated in ctx. Aborts the loop if a per-run cap is exceeded.

Circuit breaker

Three consecutive provider 5xxs trips open for 30 seconds. Cheap, effective, prevents thundering herd retries.

Sixteen more on the way.

The full repository ships 24 examples — Gemini multimodal, Ollama local, batch processing with the Anthropic Files API, Bedrock signed requests, function-calling vs. structured outputs, agent-as-test-runner, and more.

See real-world cases → SDKs & libraries