Harness engineering

Hello, model: one call, from an empty folder

September 20, 2026

The win for this sitting: go run ./cmd/hello "your question" prints a model's answer, why it stopped, and what it cost in tokens — from a module you started in an empty folder, with no SDK. About fifteen minutes of reading, then forty-five at the keyboard.

No agent yet. An agent is a loop around a model call, so the first thing to own is the call.

1. What a model call is (6 min)

Strip away the chat window and a model is a web endpoint. You POST some JSON, you get JSON back. For Anthropic's Messages API the request needs three things:

FieldWhat it is
modelWhich model to run, by id
max_tokensThe most it may write before it is cut off
messagesThe conversation so far: a list of {role, content} entries, role being user or assistant

A token is the unit a model reads and writes: a word-piece, roughly three-quarters of an English word. Everything is counted and billed in tokens, and max_tokens is a hard ceiling on the reply, not a target.

What comes back is one assistant message. Three fields matter today, and they will still matter in lesson 13:

FieldWhat it is
contentA list of blocks, not a string. Today every block has type: "text". Later the same list carries tool calls and thinking
stop_reasonWhy the model stopped writing. end_turn means it finished; max_tokens means it was cut off; tool_use (lesson 3) means it wants something run
usageinput_tokens it read and output_tokens it wrote
Key idea —

The model remembers nothing between calls. Each request stands alone: it sees exactly the messages you send and nothing else. A "conversation" is you sending a longer list each time. You will prove this to yourself in step 7, and lesson 2 is built on it.

That is the whole interface. Everything else in this course — streaming, tools, the loop — is a variation on those two tables.

2. What you need

Go 1.22 or newer (go version), and an Anthropic API key from the Claude Console. API usage is prepaid and separate from any Claude subscription. We use Claude Haiku 4.5, listed at $1 per million input tokens and $5 per million output; this whole lesson is a few hundred tokens, so well under a rupee. Buy the minimum credit and set a spend limit in the console anyway. Model ids change, so if claude-haiku-4-5-20251001 is rejected, take the current Haiku id from that same page.

3. See the wire before writing code

Project and first request0 of 3

  1. The module path is yours. If you already know where this will live, use it (something like github.com/aureliushq/yourname); if not, harness is fine and renaming later is one find-and-replace.
    mkdir harness && cd harness
    git init
    go mod init harness
    mkdir -p cmd/hello

    Done when: go.mod exists and names your module

  2. Export it for this terminal session. Do not write it to a file inside the project. If you use direnv, add .envrc to .gitignore first.
    export ANTHROPIC_API_KEY=sk-ant-…

    Done when: echo ${#ANTHROPIC_API_KEY} prints a number well above zero

  3. Read the raw reply before any Go exists. Find the three fields from section 1. Note that content is a list.
    curl -s https://api.anthropic.com/v1/messages \
      -H "x-api-key: $ANTHROPIC_API_KEY" \
      -H "anthropic-version: 2023-06-01" \
      -H "content-type: application/json" \
      -d '{"model":"claude-haiku-4-5-20251001","max_tokens":100,"messages":[{"role":"user","content":"Say hello in five words."}]}'

    Done when: JSON comes back with content, stop_reason "end_turn" and a usage object

Three headers: the key, a pinned API version (the date is the version; 2023-06-01 is still the current one), and the content type. That is all the auth there is.

4. The same call in Go

Create cmd/hello/main.go. First the shapes, copied off what curl showed you. Only the fields we use; encoding/json ignores the rest.

package main
 
import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"strings"
	"time"
)
 
const (
	defaultBaseURL = "https://api.anthropic.com"
	model          = "claude-haiku-4-5-20251001"
)
 
// What we send.
type request struct {
	Model     string    `json:"model"`
	MaxTokens int       `json:"max_tokens"`
	Messages  []message `json:"messages"`
}
 
type message struct {
	Role    string `json:"role"`
	Content string `json:"content"`
}
 
// What comes back.
type response struct {
	Content    []block `json:"content"`
	StopReason string  `json:"stop_reason"`
	Usage      usage   `json:"usage"`
}
 
type block struct {
	Type string `json:"type"`
	Text string `json:"text"`
}
 
type usage struct {
	InputTokens  int `json:"input_tokens"`
	OutputTokens int `json:"output_tokens"`
}

Then main, which is plumbing: read the key and the question, call ask, print the text blocks to stdout and the bookkeeping to stderr (so the answer stays pipeable).

func main() {
	apiKey := os.Getenv("ANTHROPIC_API_KEY")
	if apiKey == "" {
		fmt.Fprintln(os.Stderr, "set ANTHROPIC_API_KEY")
		os.Exit(1)
	}
	if len(os.Args) < 2 {
		fmt.Fprintln(os.Stderr, `usage: hello "your question"`)
		os.Exit(1)
	}
 
	ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
	defer cancel()
 
	res, err := ask(ctx, defaultBaseURL, apiKey, strings.Join(os.Args[1:], " "))
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
 
	for _, b := range res.Content {
		if b.Type == "text" {
			fmt.Println(b.Text)
		}
	}
	fmt.Fprintf(os.Stderr, "\n[stop: %s · in: %d tokens · out: %d tokens]\n",
		res.StopReason, res.Usage.InputTokens, res.Usage.OutputTokens)
}

Two choices in there that will outlive this file. ask takes a context.Context, because a model call can run for a minute and the caller must be able to give up; that becomes abort in lesson 7. And it takes the base URL as an argument instead of reading the constant, so a test can point it at a fake server and you can develop without spending tokens.

5. Your turn: write ask

Build — func ask(ctx, baseURL, apiKey, prompt) (*response, error)

Save the test file below as cmd/hello/main_test.go, then write ask in main.go until it passes. The test starts a local fake of the API with net/http/httptest, so it checks what you send as well as what you decode. My version is folded away underneath; try for ten minutes before opening it.

go test ./cmd/hello/ -v

Done when (0/3):

package main
 
import (
	"context"
	"encoding/json"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)
 
// fakeAnthropic stands in for api.anthropic.com. It records what it was sent
// and replies with whatever status and body the test asks for.
func fakeAnthropic(t *testing.T, status int, reply string) (*httptest.Server, *http.Request, *[]byte) {
	t.Helper()
	var gotReq http.Request
	var gotBody []byte
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotReq = *r
		gotBody, _ = io.ReadAll(r.Body)
		w.Header().Set("content-type", "application/json")
		w.WriteHeader(status)
		io.WriteString(w, reply)
	}))
	t.Cleanup(srv.Close)
	return srv, &gotReq, &gotBody
}
 
const okReply = `{
  "id": "msg_01", "type": "message", "role": "assistant",
  "content": [{"type": "text", "text": "Hello, Ilango."}],
  "model": "claude-haiku-4-5-20251001",
  "stop_reason": "end_turn", "stop_sequence": null,
  "usage": {"input_tokens": 12, "output_tokens": 6}
}`
 
func TestAskSendsAWellFormedRequest(t *testing.T) {
	srv, req, body := fakeAnthropic(t, 200, okReply)
 
	if _, err := ask(context.Background(), srv.URL, "test-key", "Say hello"); err != nil {
		t.Fatalf("ask: %v", err)
	}
 
	if req.Method != "POST" || req.URL.Path != "/v1/messages" {
		t.Errorf("got %s %s, want POST /v1/messages", req.Method, req.URL.Path)
	}
	if got := req.Header.Get("x-api-key"); got != "test-key" {
		t.Errorf("x-api-key = %q", got)
	}
	if got := req.Header.Get("anthropic-version"); got != "2023-06-01" {
		t.Errorf("anthropic-version = %q", got)
	}
 
	var sent struct {
		Model     string `json:"model"`
		MaxTokens int    `json:"max_tokens"`
		Messages  []struct {
			Role    string `json:"role"`
			Content string `json:"content"`
		} `json:"messages"`
	}
	if err := json.Unmarshal(*body, &sent); err != nil {
		t.Fatalf("request body is not JSON: %v\n%s", err, *body)
	}
	if sent.Model == "" || sent.MaxTokens <= 0 {
		t.Errorf("model and max_tokens are required, got %q and %d", sent.Model, sent.MaxTokens)
	}
	if len(sent.Messages) != 1 || sent.Messages[0].Role != "user" || sent.Messages[0].Content != "Say hello" {
		t.Errorf("messages = %+v, want one user message with the prompt", sent.Messages)
	}
}
 
func TestAskDecodesTheReply(t *testing.T) {
	srv, _, _ := fakeAnthropic(t, 200, okReply)
 
	res, err := ask(context.Background(), srv.URL, "test-key", "Say hello")
	if err != nil {
		t.Fatalf("ask: %v", err)
	}
	if len(res.Content) != 1 || res.Content[0].Type != "text" || res.Content[0].Text != "Hello, Ilango." {
		t.Errorf("content = %+v", res.Content)
	}
	if res.StopReason != "end_turn" {
		t.Errorf("stop reason = %q", res.StopReason)
	}
	if res.Usage.InputTokens != 12 || res.Usage.OutputTokens != 6 {
		t.Errorf("usage = %+v", res.Usage)
	}
}
 
func TestAskTurnsAnAPIErrorIntoAGoError(t *testing.T) {
	srv, _, _ := fakeAnthropic(t, 401,
		`{"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"}}`)
 
	_, err := ask(context.Background(), srv.URL, "bad-key", "Say hello")
	if err == nil {
		t.Fatal("want an error for a 401, got nil")
	}
	if !strings.Contains(err.Error(), "invalid x-api-key") {
		t.Errorf("error should carry the API's message, got: %v", err)
	}
}
 
func TestAskStopsWhenTheContextIsCancelled(t *testing.T) {
	srv, _, _ := fakeAnthropic(t, 200, okReply)
	ctx, cancel := context.WithCancel(context.Background())
	cancel()
 
	if _, err := ask(ctx, srv.URL, "test-key", "Say hello"); err == nil {
		t.Fatal("want an error from a cancelled context, got nil")
	}
}
My version of ask
func ask(ctx context.Context, baseURL, apiKey, prompt string) (*response, error) {
	body, err := json.Marshal(request{
		Model:     model,
		MaxTokens: 1024,
		Messages:  []message{{Role: "user", Content: prompt}},
	})
	if err != nil {
		return nil, fmt.Errorf("encode request: %w", err)
	}
 
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/v1/messages", bytes.NewReader(body))
	if err != nil {
		return nil, fmt.Errorf("build request: %w", err)
	}
	req.Header.Set("x-api-key", apiKey)
	req.Header.Set("anthropic-version", "2023-06-01")
	req.Header.Set("content-type", "application/json")
 
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, fmt.Errorf("send request: %w", err)
	}
	defer res.Body.Close()
 
	data, err := io.ReadAll(res.Body)
	if err != nil {
		return nil, fmt.Errorf("read response: %w", err)
	}
	if res.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("anthropic: %s: %s", res.Status, data)
	}
 
	var out response
	if err := json.Unmarshal(data, &out); err != nil {
		return nil, fmt.Errorf("decode response: %w", err)
	}
	return &out, nil
}

6. Four experiments on the real thing

Tests green means the plumbing is right. Now learn how the model behaves. Each of these costs a few dozen tokens.

Poke it0 of 5

  1. The answer goes to stdout, the bracketed line to stderr.
    go run ./cmd/hello "Explain a goroutine in two sentences."

    Done when: An answer, then [stop: end_turn · in: … · out: …]

  2. Change MaxTokens to 5, run the same question, then change it back. The sentence stops mid-word. Nothing errored: the HTTP status was 200. The only sign is the stop reason.
    go run ./cmd/hello "Explain a goroutine in two sentences."

    Done when: [stop: max_tokens · … · out: 5 tokens]

  3. Two separate runs. The second knows nothing about the first.
    go run ./cmd/hello "My favourite number is 41. Remember it."
    go run ./cmd/hello "What is my favourite number?"

    Done when: The second reply says it does not know

  4. See your error path work against the real API.
    ANTHROPIC_API_KEY=nope go run ./cmd/hello "hi"

    Done when: anthropic: 401 Unauthorized: … authentication_error …

  5. First commit of the harness.
    git add . && git commit -m "hello: one model call over net/http"

Experiment 2 is the one to remember. A cut-off reply looks like success to HTTP and to err == nil. A harness that ignores the stop reason will one day run half a tool call. pi refuses to, and so will yours.

7. Without looking back

Come back to this after a break, not straight after the experiments.

Q1. Which three fields must every Messages request carry?

Q2. A reply was cut off at the token limit. How does your program find out?

Q3. Why did the second run not know your favourite number?

Q4. What type is the content field of a reply?

Q5. Why does ask take the base URL as an argument?

The primary source is Anthropic's Messages API reference. Read the request parameters and the response fields only, and for each one ask: did my request and response structs leave this out, and would I miss it? system, temperature and stop_sequences are the ones to notice.

Terms from today — token, block, stop reason, usage — are in the glossary. Next lesson: make it a conversation. You will keep the list of messages yourself, send all of it every time, and watch input_tokens climb.