I worked through Joel Boursiquot’s GopherCon 2026 workshop, Agentic Systems the Hard Way. Zero third-party dependencies. The framing that stuck: Go is the control plane; the LLM is the decision engine. Here’s the core of it.
What an agent actually is
Model + tools + loop. The model never executes anything — it emits a
wish (“call search_docs with these args”), your Go code validates and
runs it, feeds the result back, and repeats until the model answers
without asking for tools. Deterministic Go around a nondeterministic
model. That’s the whole trick.
Local model with Ollama
ollama serve # listens on 127.0.0.1:11434
ollama pull qwen3:8b # or llama3.2:3b on 8 GB machines
Ollama’s /api/chat speaks a tool-calling protocol borrowed from
OpenAI: you POST a transcript plus tool specs, the reply may carry
tool_calls. Plain net/http and encoding/json cover it.
The loop
Tools are anything that can describe itself and run:
type Tool interface {
Name() string
Description() string
Schema() json.RawMessage
Run(ctx context.Context, args json.RawMessage) (string, error)
}
type Message struct {
Role string `json:"role"` // system, user, assistant, tool
Content string `json:"content"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
}
The loop itself, adapted from the workshop’s module 4:
func Loop(ctx context.Context, cfg Config, question string) (Result, error) {
messages := []Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: question},
}
for step := 1; step <= cfg.MaxSteps; step++ {
reply, err := callModel(ctx, cfg, messages, cfg.Tools)
if err != nil {
return Result{}, fmt.Errorf("step %d: %w", step, err)
}
if len(reply.ToolCalls) == 0 {
// No tool calls means the model is done talking.
return Result{Answer: reply.Content, Steps: step}, nil
}
messages = append(messages, reply)
messages = append(messages,
executeToolCalls(ctx, cfg.Tools, reply.ToolCalls)...)
}
return Result{Exhausted: true}, nil
}
Three policies hide in there, and each is a decision, not a framework
default: termination is “a reply with no tool calls”; hitting
MaxSteps returns a best-effort answer, not an error; and the full
transcript — every assistant reply and tool result — is carried
forward each iteration.
Lessons
- Route model mistakes back, don’t crash. An unregistered tool
name or bad JSON args becomes a
role: "tool"message likeerror: no tool named "x" is registered. The model sees its own mistake and self-corrects. Reserve Go errors for what the model can’t fix. - Prompts are code. The workshop’s system prompt encodes hard-won operational detail — search one concrete word at a time, read the full doc before answering. Version and review it like code.
- Read-only tools bound the blast radius. The corpus tools reject
path traversal (
.., separators) before touching the filesystem. - Send deterministic requests. Tool specs get sorted by name so offline tests can assert on exact request bodies.
The workshop goes further — guardrails, evals, multi-agent composition, MCP, OTel — one earned abstraction per module. Worth the four hours: jboursiquot/gc26buildingagenticsystems.