Harness engineering

The map and the transcript

September 20, 2026

The win for this sitting: the first file of your harness — the transcript types in Go — with a test that proves a conversation survives a trip through JSON. And a map in your head of where every later piece goes. About twenty minutes of reading, then an hour or so of Go.

Lessons 1 to 3 got you to a working loop in one file, the same ground as Thorsten Ball's How to Build an Agent. His summary is fair: "an LLM, a loop, and enough tokens." The rest of the course is about the distance between that and something you would let other projects depend on.

1. What the harness is (3 min)

A model is a function: a list of messages goes in, one assistant message comes out. It has no memory, runs nothing and stops after one message. The harness is everything around that function that makes it behave like an agent: keeping the list, sending it, reading the reply as it streams, running the tools the reply asks for, putting the results back on the list, and deciding whether to call again.

Key idea —

The list of messages is the whole state of an agent. We call it the transcript. Every feature in this course — streaming, tools, abort, steering, switching provider mid-conversation, saving a session — is an operation on the transcript. Get its types right and the rest has somewhere to stand.

2. Two layers, one direction (5 min)

pi splits the harness into two packages, and we will too.

The provider layer (pi-ai) puts many LLM APIs behind one set of types and one streaming call. It knows wire formats, auth, token usage and cost. It knows nothing about loops and never runs a tool. Mario Zechner, pi's author, counts four wire formats worth speaking — OpenAI Completions, OpenAI Responses, Anthropic Messages and Google Generative AI — and nearly every other host copies one of them.

The agent core (pi-agent-core) is the loop: it calls the provider layer, runs tools, emits events for a UI, and handles messages that arrive while it is busy. It is small. agent-loop.ts is 857 lines, and the loop proper is about 120 of them.

your app ──► agent core ──► provider layer ──► HTTPS ──► model

The arrow only points right. The agent core imports the provider layer; the provider layer imports neither. That rule is what lets you use the provider layer alone (a one-shot completion in a CLI) and test the agent core with a fake provider and no network.

Armin Ronacher's advice after a year of building agents is to own this abstraction yourself because the differences between models leak through any generic SDK. That is the case for this course existing.

Which layer owns it?0 of 6

Turning Anthropic’s input_json_delta chunks into one tool call block

Looking up the Go function registered under the name read_file

Counting input and output tokens and pricing them

Deciding that two tool calls can run at the same time

Refusing to execute tool calls from a reply cut off by the token limit

Replaying a thinking block’s opaque signature on the next request

3. The transcript is the contract (8 min)

Both layers share one thing: the message types. pi defines them in packages/ai/src/types.ts, lines 358–553. Stripped to what matters now:

RoleContent blocksExtra fields
systemtext
usertext, image
assistanttext, thinking, tool callprovider, model, usage, stop reason, error message
toolResulttext, imagetool call id, tool name, is-error

Three things in there are decisions, not accidents.

They are your types, not a vendor's. Anthropic calls it tool_use, OpenAI calls it a function_call item, Google a functionCall part. If the agent core holds vendor types, it is a one-vendor agent core. Owning the types is also what makes handing a conversation from one provider to another possible, and what makes a saved session readable next year.

Content is a list of blocks, not a string. One assistant message can think, say something and ask for two tools, in that order. The order is information. A thinking block also carries an opaque signature that the provider expects back unchanged; lose it and the next request can fail.

The stop reason is part of the message. It is the model's hand signal to the loop:

Stop reasonMeaningThe loop's move
stopFinishedNothing to run. End, unless something is queued
toolUseWants tools runRun them, append results, call again
lengthHit the output token limitTool calls may be cut off mid-argument. Fail them all, run none, call again
errorRequest failedEnd the run; partial content stays on the message
abortedCaller cancelledSame as error

Read lines 217–247 of agent-loop.ts with that table beside you. One detail to notice: the loop finds work by looking for tool call blocks in the content, not by checking for toolUse. The stop reason only decides whether running them is safe. The loop from lesson 3 executes a truncated tool call today.

4. Saying it in Go (4 min)

TypeScript gives pi tagged unions for free. Go does not, so there are two choices to make before typing anything.

Blocks: a sealed interface. type Block interface{ blockType() string } with an unexported method, implemented by Text, Thinking, Image and ToolCall. The reason: a type switch is how Go code wants to consume a union, and the unexported method stops other packages adding block kinds the provider files do not know how to encode. The cost: encoding/json cannot decode into an interface, so Message needs hand-written MarshalJSON and UnmarshalJSON that read and write a "type" tag. Accept it; it is forty lines, written once.

Messages: one struct, optional fields. A single Message with Role, Content []Block and the role-specific fields marked omitempty. The reason: []Message is the type that appears in every signature in both packages, and a plain struct slice is pleasant to build, range over and serialise. The cost: the compiler will not stop you setting ToolCallID on a user message. A Validate() method can, later. The alternative — a sealed Message interface with four structs — is more correct and makes every call site noisier. This is reversible while the module is pre-v0.1, so take the simple one.

Two smaller ones. Keep tool call arguments as json.RawMessage: the provider layer should not guess at their shape, and validation belongs to the tool. And store timestamps as int64 Unix milliseconds, as pi does. time.Time does not survive a JSON round trip under reflect.DeepEqual (monotonic clock reading, location pointer), and you would find that out in the test below.

5. Build it

Build — The transcript types, with a round trip

Add a package llm to the module. In llm/transcript.go define Role and StopReason as string types with constants, the Block interface and its four implementations, Usage with Input and Output counts, and Message. Add one helper, func (m Message) ToolCalls() []ToolCall. Then save the test below as llm/transcript_test.go and make it pass. The test is the spec: field and constant names come from it.

go test ./llm/ -run "Transcript|Block|ToolCalls" -v

Done when (0/4):

package llm
 
import (
	"encoding/json"
	"reflect"
	"strings"
	"testing"
)
 
func sampleTranscript() []Message {
	return []Message{
		{Role: RoleUser, Content: []Block{Text{Text: "Which Go version does this repo use?"}}, Timestamp: 1},
		{
			Role: RoleAssistant,
			Content: []Block{
				Thinking{Thinking: "I should read go.mod.", Signature: "sig-abc"},
				Text{Text: "Let me check."},
				ToolCall{ID: "call_1", Name: "read_file", Arguments: json.RawMessage(`{"path":"go.mod"}`)},
			},
			Provider:   "anthropic",
			Model:      "claude-sonnet-4-6",
			StopReason: StopToolUse,
			Usage:      &Usage{Input: 120, Output: 45},
			Timestamp:  2,
		},
		{
			Role:       RoleToolResult,
			ToolCallID: "call_1",
			ToolName:   "read_file",
			Content:    []Block{Text{Text: "module harness\n\ngo 1.24\n"}},
			Timestamp:  3,
		},
		{
			Role:       RoleAssistant,
			Content:    []Block{Text{Text: "Go 1.24."}},
			Provider:   "anthropic",
			Model:      "claude-sonnet-4-6",
			StopReason: StopEnd,
			Usage:      &Usage{Input: 190, Output: 6},
			Timestamp:  4,
		},
	}
}
 
func TestTranscriptRoundTrip(t *testing.T) {
	want := sampleTranscript()
 
	data, err := json.Marshal(want)
	if err != nil {
		t.Fatalf("marshal: %v", err)
	}
 
	var got []Message
	if err := json.Unmarshal(data, &got); err != nil {
		t.Fatalf("unmarshal: %v\njson: %s", err, data)
	}
	if !reflect.DeepEqual(got, want) {
		t.Fatalf("round trip changed the transcript\n got: %#v\nwant: %#v", got, want)
	}
}
 
func TestBlocksCarryATypeTag(t *testing.T) {
	data, err := json.Marshal(sampleTranscript()[1])
	if err != nil {
		t.Fatalf("marshal: %v", err)
	}
	for _, tag := range []string{`"type":"thinking"`, `"type":"text"`, `"type":"toolCall"`} {
		if !strings.Contains(string(data), tag) {
			t.Errorf("missing %s in %s", tag, data)
		}
	}
}
 
func TestUnknownBlockTypeIsAnError(t *testing.T) {
	in := `{"role":"user","content":[{"type":"hologram","text":"hi"}],"timestamp":1}`
	var m Message
	if err := json.Unmarshal([]byte(in), &m); err == nil {
		t.Fatal("want an error for an unknown block type, got nil")
	}
}
 
func TestToolCallsHelper(t *testing.T) {
	msgs := sampleTranscript()
	if got := msgs[1].ToolCalls(); len(got) != 1 || got[0].Name != "read_file" {
		t.Errorf("ToolCalls() = %#v, want one read_file call", got)
	}
	if got := msgs[3].ToolCalls(); len(got) != 0 {
		t.Errorf("ToolCalls() = %#v, want none", got)
	}
}

I have run this test against a reference implementation on Go 1.24, so it is passable as written. Paste me your transcript.go when it is green and I will review it the way a maintainer would.

6. Without looking back

Do this after the build, not straight after the reading. The gap is the point.

Q1. A reply stops with reason length and contains two tool call blocks. What does pi’s loop do?

Q2. How does the loop decide there are tools to run?

Q3. Which import would break the two-layer rule?

Q4. Why must a thinking block keep its signature in the transcript?

Q5. Why are tool call arguments kept as raw JSON in the provider layer?

The primary source for this lesson is Mario Zechner's What I learned building an opinionated and minimal coding agent. Read the pi-ai and pi-agent-core sections only, and for each feature he lists, say which layer on your map it lives in. Then skim types.ts lines 358–553 and note every field you left out of your Message. Each one is a lesson we have not reached yet, or a thing you have decided not to build.

The harness map has this lesson on one sheet, and the glossary has the terms. Next lesson: what comes back from a model call while it is still being generated — the stream contract — and a fake provider, so that everything after it can be tested with no network and no API key.