Automate the Boring Stuff with Go, Ch. 20: Automate with a Local Model
Part of Automate the Boring Stuff with Go, a beginner-friendly guide to programming in Go.
New in the 2026 edition.
Everything so far has been exact: a regex matches or it does not, a cell holds a number or it does not. Some boring work is not exact. “Which of these seven categories is a taxi receipt?” “What is this note about, in three words?” Those used to need a person. A small language model running on your own laptop handles them well enough to be useful, and this chapter shows how to use one from Go without handing it the keys.
The rules are the chapter. The model runs locally, so nothing you feed it leaves your machine. The model only ever proposes; your Go code decides, by checking every answer against a schema and an allowlist before anything happens. And every program here has a dry run, like every other file-changing program in this book. The code is under ch20.
Setup
Ollama is the simplest local model server: one program that downloads models and serves them over HTTP on your own machine. Nothing in this chapter works without it, so here is the whole setup.
1. Install Ollama.
- macOS: download the app from ollama.com/download and drag it to Applications, or
brew install ollama. Open it once; it runs in the menu bar and starts its server for you. - Windows: the installer from the same page. It runs in the background with a tray icon after install.
- Linux: the page gives a one-line installer script that sets up a system service. If you would rather see what you run, download the script first and read it, or use your distribution’s package where one exists. Start it with
sudo systemctl start ollama, orollama servein a terminal you keep open.
2. Check it is running. In any terminal:
ollama --version
curl http://localhost:11434/api/version
The second command prints a small JSON document when the server is up. “Connection refused” means it is not: open the app (macOS, Windows) or run ollama serve (Linux).
3. Pull a model.
ollama pull qwen2.5:1.5b
That is about a 1 GB download, once. The model needs roughly 2 GB of memory while it answers and runs on an ordinary laptop CPU, comfortably on 8 GB of RAM; a short question takes a second or two. ollama list shows what you have. If you have 16 GB and patience, qwen2.5:7b (4.7 GB) gives better answers to the same programs; set LLM_MODEL=qwen2.5:7b.
Ollama, llama.cpp’s server and LM Studio all speak the same HTTP API (the OpenAI chat format), so the Go code below works with any of them; LLM_URL and LLM_MODEL point it elsewhere. Every program in the chapter prints a plain explanation, with the two commands above, when it cannot reach a server.
The client
The whole client is one function: build a JSON request, POST it, read the answer out of the JSON response.
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
// Client talks to a model server running on your own machine that speaks the OpenAI
// chat API, which Ollama, llama.cpp's server and LM Studio all do. Nothing leaves the
// machine.
type Client struct {
BaseURL string // e.g. http://localhost:11434/v1
Model string // e.g. qwen2.5:1.5b
HTTP *http.Client
}
// FromEnv reads LLM_URL and LLM_MODEL, with Ollama's defaults.
func FromEnv() Client {
c := Client{BaseURL: "http://localhost:11434/v1", Model: "qwen2.5:1.5b", HTTP: &http.Client{Timeout: 2 * time.Minute}}
if v := os.Getenv("LLM_URL"); v != "" {
c.BaseURL = v
}
if v := os.Getenv("LLM_MODEL"); v != "" {
c.Model = v
}
return c
}
type message struct {
Role string `json:"role"`
Content string `json:"content"`
}
type responseFormat struct {
Type string `json:"type"`
}
type chatRequest struct {
Model string `json:"model"`
Temperature float64 `json:"temperature"`
Messages []message `json:"messages"`
ResponseFormat *responseFormat `json:"response_format,omitempty"`
}
type chatResponse struct {
Choices []struct {
Message message `json:"message"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
// Chat sends one system prompt and one user prompt and returns the model's text. With
// jsonMode the server is asked for a JSON object; the caller still validates it.
func (c Client) Chat(ctx context.Context, system, user string, jsonMode bool) (string, error) {
req := chatRequest{Model: c.Model, Temperature: 0, Messages: []message{{"system", system}, {"user", user}}}
if jsonMode {
req.ResponseFormat = &responseFormat{Type: "json_object"}
}
body, err := json.Marshal(req)
if err != nil {
return "", err
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimSuffix(c.BaseURL, "/")+"/chat/completions", bytes.NewReader(body))
if err != nil {
return "", err
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.HTTP.Do(httpReq)
if err != nil {
return "", fmt.Errorf("model server at %s: %w (is it running? try: ollama serve, then ollama pull %s)", c.BaseURL, err, c.Model)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", err
}
var out chatResponse
if err := json.Unmarshal(raw, &out); err != nil || resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("model server: %s: %s", resp.Status, strings.TrimSpace(string(raw)))
}
if out.Error != nil {
return "", errors.New("model server: " + out.Error.Message)
}
if len(out.Choices) == 0 {
return "", errors.New("model server returned no choices")
}
return out.Choices[0].Message.Content, nil
}
// ExtractJSON returns the first {...} object in s, for models that wrap their JSON in
// prose or code fences despite being asked not to.
func ExtractJSON(s string) (string, bool) {
start := strings.Index(s, "{")
end := strings.LastIndex(s, "}")
if start < 0 || end <= start {
return "", false
}
return s[start : end+1], true
}
// chat-once sends one question to a model running on your own machine and prints the
// answer. Nothing is sent anywhere else.
//
// ollama pull qwen2.5:1.5b
// go run ./ch20/examples/chat-once "In one sentence, what is a goroutine?"
package main
import (
"context"
"errors"
"fmt"
"os"
"strings"
"time"
)
func run(args []string) error {
if len(args) == 0 {
return errors.New("usage: chat-once <question>")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
client := FromEnv()
start := time.Now()
answer, err := client.Chat(ctx, "You are a concise assistant. Answer in plain text.", strings.Join(args, " "), false)
if err != nil {
return err
}
fmt.Println(strings.TrimSpace(answer))
fmt.Fprintf(os.Stderr, "(%s, %s)\n", client.Model, time.Since(start).Round(100*time.Millisecond))
return nil
}
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
$ go run ./ch20/examples/chat-once "In one sentence, what is a goroutine?"
A goroutine is a lightweight thread that can be scheduled to run concurrently with other goroutines in Go programming language.
(qwen2.5:1.5b, 1.5s)
temperature: 0 asks for the most likely answer every time, which makes runs more repeatable (not perfectly: a different model version answers differently). The response_format field asks the server for a JSON object when we want one. ExtractJSON exists because small models sometimes wrap their JSON in prose or code fences anyway.
Structured output: ask for a shape, then check it
Free text is pleasant to read and useless to a program. The useful pattern is: describe the exact JSON you want in the prompt, decode it into a struct, and validate every field with ordinary Go code. When validation fails, tell the model what was wrong and ask once more.
// structured-output asks the model for JSON in a shape we define, decodes it into a
// struct, and checks every field before believing any of it. When the check fails, the
// model gets one more try with the complaint attached.
//
// go run ./ch20/examples/structured-output "Picked up 3 kg of apples at the market for 7.50 on Tuesday"
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"slices"
"strings"
"time"
)
// Purchase is the shape we want back. The tags are the contract with the prompt.
type Purchase struct {
Item string `json:"item"`
Quantity float64 `json:"quantity"`
Unit string `json:"unit"`
Price float64 `json:"price"`
}
var units = []string{"kg", "g", "l", "ml", "piece", "pack"}
// Validate is the part the model does not get a vote in.
func (p Purchase) Validate() error {
var problems []string
if strings.TrimSpace(p.Item) == "" || len(p.Item) > 60 {
problems = append(problems, "item must be 1 to 60 characters")
}
if p.Quantity <= 0 || p.Quantity > 10000 {
problems = append(problems, "quantity must be between 0 and 10000")
}
if !slices.Contains(units, p.Unit) {
problems = append(problems, "unit must be one of "+strings.Join(units, ", "))
}
if p.Price < 0 || p.Price > 100000 {
problems = append(problems, "price must be between 0 and 100000")
}
if len(problems) > 0 {
return errors.New(strings.Join(problems, "; "))
}
return nil
}
const system = `Extract a purchase from the user's sentence. Reply with JSON only, exactly this shape:
{"item": "<short noun phrase>", "quantity": <number>, "unit": "<one of: kg, g, l, ml, piece, pack>", "price": <number, total paid>}
Use "piece" when no unit is given. Do not add fields or commentary.`
// Extract asks, validates, and retries once with the validation error fed back.
func Extract(ctx context.Context, c Client, sentence string) (Purchase, error) {
user := sentence
var last error
for attempt := 1; attempt <= 2; attempt++ {
reply, err := c.Chat(ctx, system, user, true)
if err != nil {
return Purchase{}, err
}
obj, ok := ExtractJSON(reply)
if !ok {
last = fmt.Errorf("no JSON object in reply %q", reply)
} else {
var p Purchase
if err := json.Unmarshal([]byte(obj), &p); err != nil {
last = fmt.Errorf("bad JSON %q: %w", obj, err)
} else if err := p.Validate(); err != nil {
last = fmt.Errorf("invalid: %w (reply was %s)", err, obj)
} else {
return p, nil
}
}
user = fmt.Sprintf("%s\n\nYour previous answer was rejected: %v. Reply again with valid JSON only.", sentence, last)
}
return Purchase{}, fmt.Errorf("gave up after 2 attempts: %w", last)
}
func run(args []string) error {
if len(args) == 0 {
return errors.New("usage: structured-output <sentence describing a purchase>")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
p, err := Extract(ctx, FromEnv(), strings.Join(args, " "))
if err != nil {
return err
}
fmt.Printf("%+v\n", p)
return nil
}
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
$ go run ./ch20/examples/structured-output "Picked up 3 kg of apples at the market for 7.50 on Tuesday"
{Item:apples Quantity:3 Unit:kg Price:7.5}
Read Validate as the contract. The model can say anything; only a Purchase with a short item, a sane quantity, a unit from our list and a plausible price gets past it. That function is the difference between “we use AI” and a program you can trust in a loop over ten thousand receipts.
Project: categorize expenses
The first project fills in the category column of an expenses CSV, the one chapter 12 left blank. Seven allowed categories, one question per distinct item, and an answer outside the list becomes “other”.
// categorize-expenses fills in the category column of an expenses CSV by asking a local
// model to pick one from a fixed list. The model proposes; the program decides: an
// answer outside the list becomes "other", every answer can be logged, repeated items are
// asked once, and nothing is written until you have looked at the table.
//
// go run ./ch20/projects/categorize-expenses ./ch20/projects/categorize-expenses/uncategorized.csv
// go run ./ch20/projects/categorize-expenses -write ./ch20/projects/categorize-expenses/uncategorized.csv
package main
import (
"context"
"encoding/csv"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"log/slog"
"os"
"slices"
"strings"
"text/tabwriter"
"time"
)
// Categories is the allowlist. The model never adds to it.
var Categories = []string{"food", "transport", "books", "household", "health", "entertainment", "other"}
// Row is one expense before and after.
type Row struct {
Date, Item, Amount, Category string
}
// Prompt is what the model sees for one item: the allowed answers and the exact JSON
// shape, and nothing about the program, the file, or what happens next.
func Prompt(item, amount string) (system, user string) {
system = fmt.Sprintf(`You classify personal expenses. Reply with JSON only: {"category": "<one of: %s>"}`, strings.Join(Categories, ", "))
user = fmt.Sprintf("Item: %q, amount: %s", item, amount)
return system, user
}
// Parse turns the model's reply into a category from the allowlist, or "other" with a
// reason. It never returns anything that is not in Categories.
func Parse(reply string) (category, reason string) {
obj, ok := ExtractJSON(reply)
if !ok {
return "other", "no JSON in reply"
}
var v struct {
Category string `json:"category"`
}
if err := json.Unmarshal([]byte(obj), &v); err != nil {
return "other", "bad JSON"
}
c := strings.ToLower(strings.TrimSpace(v.Category))
if !slices.Contains(Categories, c) {
return "other", fmt.Sprintf("%q is not an allowed category", c)
}
return c, ""
}
// Classifier asks the model, with a memory so the same item is asked once.
type Classifier struct {
Client Client
cache map[string]string
Calls int
}
// Category returns the category for one item, asking the model only the first time.
func (k *Classifier) Category(ctx context.Context, item, amount string) string {
if k.cache == nil {
k.cache = map[string]string{}
}
key := strings.ToLower(strings.TrimSpace(item))
if c, ok := k.cache[key]; ok {
return c
}
system, user := Prompt(item, amount)
k.Calls++
reply, err := k.Client.Chat(ctx, system, user, true)
if err != nil {
slog.Warn("model call failed", "item", item, "err", err)
k.cache[key] = "other"
return "other"
}
c, reason := Parse(reply)
slog.Info("classified", "item", item, "category", c, "reply", strings.TrimSpace(reply), "note", reason)
k.cache[key] = c
return c
}
// ReadRows reads date,item,amount[,category] CSV, columns in any order.
func ReadRows(r io.Reader) ([]Row, error) {
records, err := csv.NewReader(r).ReadAll()
if err != nil {
return nil, err
}
if len(records) < 2 {
return nil, errors.New("no data rows")
}
col := map[string]int{}
for i, h := range records[0] {
col[strings.ToLower(strings.TrimSpace(h))] = i
}
for _, need := range []string{"date", "item", "amount"} {
if _, ok := col[need]; !ok {
return nil, fmt.Errorf("missing column %q", need)
}
}
var rows []Row
for _, rec := range records[1:] {
row := Row{Date: rec[col["date"]], Item: rec[col["item"]], Amount: rec[col["amount"]]}
if i, ok := col["category"]; ok && i < len(rec) {
row.Category = rec[i]
}
rows = append(rows, row)
}
return rows, nil
}
// Fill classifies every row that has no category yet.
func Fill(ctx context.Context, k *Classifier, rows []Row) {
for i := range rows {
if rows[i].Category == "" {
rows[i].Category = k.Category(ctx, rows[i].Item, rows[i].Amount)
}
}
}
func writeRows(w io.Writer, rows []Row) error {
cw := csv.NewWriter(w)
cw.Write([]string{"date", "item", "amount", "category"})
for _, r := range rows {
cw.Write([]string{r.Date, r.Item, r.Amount, r.Category})
}
cw.Flush()
return cw.Error()
}
func run(args []string) error {
flags := flag.NewFlagSet("categorize-expenses", flag.ContinueOnError)
write := flags.Bool("write", false, "write <name>-categorized.csv (default: print the table)")
if err := flags.Parse(args); err != nil {
return err
}
if flags.NArg() != 1 {
return errors.New("usage: categorize-expenses [-write] <expenses.csv>")
}
f, err := os.Open(flags.Arg(0))
if err != nil {
return err
}
rows, err := ReadRows(f)
f.Close()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
k := &Classifier{Client: FromEnv()}
start := time.Now()
Fill(ctx, k, rows)
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "date\titem\tamount\tcategory")
for _, r := range rows {
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", r.Date, r.Item, r.Amount, r.Category)
}
tw.Flush()
fmt.Fprintf(os.Stderr, "%d row(s), %d model call(s), %s\n", len(rows), k.Calls, time.Since(start).Round(100*time.Millisecond))
if *write {
out := strings.TrimSuffix(flags.Arg(0), ".csv") + "-categorized.csv"
of, err := os.Create(out)
if err != nil {
return err
}
if err := writeRows(of, rows); err != nil {
of.Close()
return err
}
if err := of.Close(); err != nil {
return err
}
fmt.Println("wrote", out)
}
return nil
}
func main() {
level := slog.LevelWarn
if os.Getenv("LLM_LOG") != "" {
level = slog.LevelInfo // LLM_LOG=1 shows every prompt's answer
}
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level})))
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
$ go run ./ch20/projects/categorize-expenses ./ch20/projects/categorize-expenses/uncategorized.csv
date item amount category
2026-10-01 monthly bus pass 32.00 transport
2026-10-02 coffee beans 1kg 14.50 food
2026-10-02 paracetamol 3.99 health
2026-10-03 The Go Programming Language, used 18.00 books
2026-10-04 cinema tickets x2 24.00 entertainment
2026-10-05 dish soap and sponges 6.75 household
2026-10-05 coffee beans 1kg 14.50 food
2026-10-06 taxi to the airport 41.20 transport
2026-10-07 lunch with Sam 19.80 food
2026-10-08 socks 9.99 household
10 row(s), 9 model call(s), 2.1s
Ten rows, nine calls, two seconds: the second bag of coffee beans came from the cache. Parse is the gate, and its test feeds it a made-up category, prose, a number and an empty string and checks that every one comes out as “other”. Set LLM_LOG=1 and every item, reply and decision is logged, which is how you find out that the model files socks under household and decide whether you agree.
The tests do not need a model. A local HTTP server speaking the same API answers from a table, so Fill can be checked for caching, for respecting pre-filled rows and for turning a bad answer into “other” in a quarter of a second. One more test, skipped unless LLM_LIVE=1, asks the real model about a bus pass.
Project: name your notes
The second project looks at every text note in a folder and suggests a file name that says what it is about. The model supplies a candidate; a strict pattern decides whether it is acceptable; a deterministic tidy step turns budget_meeting_minutes into budget-meeting-minutes first; collisions get a number; nothing is renamed without -apply.
// name-notes suggests a descriptive file name for every text note in a folder by asking
// a local model what each note is about. The model only ever supplies a candidate name;
// a strict pattern decides whether it is acceptable, collisions are resolved by the
// program, and nothing is renamed without -apply.
//
// go run ./ch20/projects/name-notes ./ch20/projects/name-notes/sample
// go run ./ch20/projects/name-notes -apply ./ch20/projects/name-notes/sample
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
// validName is the only shape a new name may take: two to six lowercase words joined by
// single hyphens. No path separators, no dots, no spaces, nothing surprising.
var validName = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+){1,5}$`)
const system = `You name text files. Given the contents of a note, reply with JSON only:
{"name": "<2 to 6 lowercase words joined by hyphens that say what the note is about>"}`
// Suggest asks the model for a name and returns it only if it passes the pattern.
func Suggest(ctx context.Context, c Client, contents string) (string, error) {
// a note can be long; the first few hundred characters say what it is about
excerpt := contents
if len(excerpt) > 600 {
excerpt = excerpt[:600]
}
reply, err := c.Chat(ctx, system, "Note contents:\n"+excerpt, true)
if err != nil {
return "", err
}
return Accept(reply)
}
// Accept extracts the name from the reply, tidies it the way a person would (lower
// case, hyphens for spaces and underscores, punctuation dropped) and then checks it
// against the pattern. The tidying is deterministic code; the pattern is still the gate.
func Accept(reply string) (string, error) {
obj, ok := ExtractJSON(reply)
if !ok {
return "", fmt.Errorf("no JSON in reply %q", reply)
}
var v struct {
Name string `json:"name"`
}
if err := json.Unmarshal([]byte(obj), &v); err != nil {
return "", fmt.Errorf("bad JSON %q", obj)
}
name := Tidy(v.Name)
if !validName.MatchString(name) {
return "", fmt.Errorf("suggested name %q does not match %s", v.Name, validName)
}
return name, nil
}
// Tidy lower-cases, turns runs of spaces, underscores and hyphens into one hyphen, and
// drops every other character that is not a letter or digit.
func Tidy(s string) string {
var b strings.Builder
lastHyphen := true
for _, r := range strings.ToLower(strings.TrimSpace(s)) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
lastHyphen = false
case r == ' ' || r == '_' || r == '-':
if !lastHyphen {
b.WriteByte('-')
lastHyphen = true
}
}
}
return strings.TrimSuffix(b.String(), "-")
}
// Rename is one planned change.
type Rename struct {
From, To string
Note string // why there is no To, when there is not
}
// Plan proposes a new name for every .txt and .md file in folder. Names the model gets
// wrong are left as they are, with the reason; duplicate suggestions get a -2, -3 suffix.
func Plan(ctx context.Context, c Client, folder string) ([]Rename, error) {
entries, err := os.ReadDir(folder)
if err != nil {
return nil, err
}
taken := map[string]bool{}
for _, e := range entries {
taken[e.Name()] = true
}
var plan []Rename
for _, e := range entries {
ext := strings.ToLower(filepath.Ext(e.Name()))
if e.IsDir() || (ext != ".txt" && ext != ".md") {
continue
}
contents, err := os.ReadFile(filepath.Join(folder, e.Name()))
if err != nil {
return nil, err
}
name, err := Suggest(ctx, c, string(contents))
if err != nil {
plan = append(plan, Rename{From: e.Name(), Note: err.Error()})
continue
}
candidate := name + ext
for n := 2; taken[candidate]; n++ {
candidate = fmt.Sprintf("%s-%d%s", name, n, ext)
}
taken[candidate] = true
plan = append(plan, Rename{From: e.Name(), To: candidate})
}
return plan, nil
}
func run(args []string) error {
flags := flag.NewFlagSet("name-notes", flag.ContinueOnError)
apply := flags.Bool("apply", false, "rename the files (default: print the plan)")
if err := flags.Parse(args); err != nil {
return err
}
if flags.NArg() != 1 {
return errors.New("usage: name-notes [-apply] <folder>")
}
folder := flags.Arg(0)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
plan, err := Plan(ctx, FromEnv(), folder)
if err != nil {
return err
}
for _, r := range plan {
if r.To == "" {
fmt.Printf("%-14s kept (%s)\n", r.From, r.Note)
continue
}
fmt.Printf("%-14s -> %s\n", r.From, r.To)
if *apply {
if err := os.Rename(filepath.Join(folder, r.From), filepath.Join(folder, r.To)); err != nil {
return err
}
}
}
if !*apply {
fmt.Println("(dry run; pass -apply to rename)")
}
return nil
}
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
$ go run ./ch20/projects/name-notes ./ch20/projects/name-notes/sample
note1.txt -> budget-meeting-minutes.txt
note2.txt -> grandmas-lemon-cake.txt
note3.txt -> packing-list-lisbon-trip.txt
note4.txt -> text-file.txt
note5.txt -> final-version-2-q4-notes.txt
(dry run; pass -apply to rename)
The fourth note is three nonsense words, and the model shrugged with “text file”. That is the right outcome: a bland valid name, which the dry run shows you before anything moves.
The fifth note is the interesting one. Its text asks, in so many words, to be filed as FINAL version (2)/Q4 notes!!, slash and all. The model passed that wish along, and the program did what it does with every answer: Tidy kept the letters, digits and hyphens and dropped the rest, the pattern approved, and the result is an ordinary file name in the folder it belongs in. The note got a vote on what the name says. It never got a vote on what a name is allowed to be. Keep that distinction and a model can read anything you hand it. Before the tidy step was added, the model’s suggestions for the first two notes were rejected for an underscore and an apostrophe; its judgement was fine, its typing was not, and a few lines of Go fixed the typing. That division of labour, judgement from the model, discipline from the code, is the whole chapter in one sentence.
What models are for, and not for
Good uses: classify into a fixed list, extract a few fields from messy text, suggest a name or a summary, decide which of two templates fits. Each one has a shape you can validate.
Poor uses: arithmetic, dates, anything with a right answer you could compute (chapter 13’s time.Parse beats any model at reading dates), and any decision whose cost of being wrong is high. Never let a model’s text choose a file to delete, a command to run or an address to send to; have it choose from a list your code made, and check the choice.
The model’s reply is input, like a web page or a spreadsheet cell someone else typed: it can be malformed, it can be wrong, and the text in the note you fed it might have been written to steer the answer. The validators in this chapter treat it that way, and so should yours.
Conclusion
A local model is one HTTP call away from Go, and it is safe to use the day you treat its output as data to be checked rather than instructions to be followed. Ask for a shape, validate every field, cache, log, dry-run, and keep the judgement calls small. Done that way, it is just another tool in the box this book has been filling, and the box is full enough now to build almost anything you are bored of doing by hand.
Where this leads
If the fifth note made you curious, you have found the edge of this book. The programs here keep the model on a short leash: one question, one answer, a pattern that decides. The moment you let a model read your email, pick which tool to run next, or act on your behalf, the leash is the whole problem, and notes like that fifth one stop being a curiosity. Text that tries to steer the model is a real category of attack, and so are a dozen others with less obvious shapes.
That is the subject of the next book, AI Security Engineering. It builds a small agent in Go, the same language and the same habits as here, then attacks it chapter by chapter and fixes what breaks, until every defense is one you have watched hold. If chapter 20 felt like the start of something rather than the end, it is the natural next step.