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 like error: 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.