Each example below is a self-contained Go module — read it top to bottom, then go run ./cmd/<name>. Every example deliberately uses one provider SDK and the standard library. Nothing else. The whole point is to read the code without first learning a framework.
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.
A 60-line ReAct loop with two tools.
EX-02Generic registry + JSON schema codegen.
EX-03net/http server streaming a model.
EX-04Ingest, chunk, embed, query.
EX-05read · write · bash · run_tests.
EX-06Orchestrator + parallel workers.
EX-07Connect to any MCP server.
EX-08OTel · retries · cost · circuit breaker.
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.
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...))
}
}
export ANTHROPIC_API_KEY=...
go run ./cmd/hello
Set slog to debug — you'll see two tool calls and one text turn before the loop exits.
Add a third tool. Add a goroutine to run tool calls in parallel. Swap the model for Haiku 4.5.
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.
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)
})
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.
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")
}
<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>
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.
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);
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 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.
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.`
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.
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.
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.
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")
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.
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
}
One span per agent run, child spans per turn, child-of-child spans per tool call. The trace is the debugger.
Token counts × per-model rates, accumulated in ctx. Aborts the loop if a per-run cap is exceeded.
Three consecutive provider 5xxs trips open for 30 seconds. Cheap, effective, prevents thundering herd retries.
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.