A harness grows through documented extension points, never by changing the model or forking the core. The rule holds for both kinds of harness this module meets: the agent harness a webmaster works in, such as Claude Code, and the tandem harness built in The tandem harness and carried over its wire.
The shipped harness this lesson extends, the tandem harness and its wire, comes from the earlier lessons; here its client is cut into a core and what plugs into it, so the lesson can stay on the points where each one grows.
One question sorts them: is this judgment, or must it happen every time? Judgment goes where the model reads it. Anything that must happen every time goes to code that runs whether or not the model remembers. Writing an agentic skill built one kind of judgment; this lesson places it among the rest.
Extend, don’t fork
A harness, as The tandem harness defined it, is the program around a model: the loop, the tools, the permissions and the context it works in. Five kinds of thing can be added to one: context, know-how, isolated helpers, deterministic rules and outside tools. Two stay fixed: the model and the core loop.
The difference shows at the next update. An extension rides along, because it sits where the harness expects something to sit. A patched core has to be patched again, by hand, every time the harness moves.
Judgment, or every time
Claude Code’s guide, Extend Claude Code, lists its extension points and when each loads. Six of them carry this lesson:
CLAUDE.mdorAGENTS.mdloads every session, in full: what the agent must always know.- A skill loads its description at the start and the rest when it’s used: know-how a task needs sometimes.
- A subagent defined in a file loads when spawned, in a fresh context: work that would flood the conversation, or a review that starts clean.
- A hook runs on its event, outside the conversation: anything that must happen every time.
- An MCP server loads tool names at the start and schemas when used: tools and data from another system.
- A plugin is the packaging: what it bundles loads as its own kind does, for the same setup in a second repository.
Claude Code (2.1.277 or later) reads AGENTS.md when no CLAUDE.md, .claude/CLAUDE.md or CLAUDE.local.md sits in the working directory or above it, so, by default, a private CLAUDE.local.md stops it reading AGENTS.md; setting Project instructions to claude-md-and-agents-md loads both. A CLAUDE.md can import it with @AGENTS.md, so one file serves every coding agent. The guide adds output styles and a few more points; these six cover the moves here.
The guide is plain about the sorting: an instruction in CLAUDE.md or a skill is a request, not a guarantee, while a hook that blocks is enforcement. A rule that must hold every time becomes a hook, or a check in CI.
To try it, sort these five before reading on. The agent got the same convention wrong twice. Every page edit must pass the page check. A playbook is pasted into chat for the third time. A side task floods the conversation with logs. The price list lives in a system the agent can’t see.
In order: the instructions file, a hook, a skill, a subagent and an MCP server. Each matches a trigger in the guide’s own table for building a setup over time.
Hooks: rules the model can’t forget
A hook is a handler Claude Code runs at a lifecycle event: a shell command, an HTTP request, an MCP tool call, a prompt for a model, or a subagent. It fires on every matching event, whatever the model has in mind. The hooks reference lists the events; two carry this example. PostToolUse fires after a tool succeeds, and Stop fires when the agent finishes its turn.
The example is an invented webmaster’s repository for a bakery’s site. Its project settings, .claude/settings.json, are committed with the code:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "node", "args": ["${CLAUDE_PROJECT_DIR}/scripts/hook-gate.ts"] }
]
}
],
"Stop": [
{
"hooks": [
{ "type": "command", "command": "node", "args": ["${CLAUDE_PROJECT_DIR}/scripts/hook-gate.ts"] }
]
}
]
}
}
The matcher Edit|Write is a list of exact tool names. Stop takes no matcher and fires whenever the agent finishes responding on its own; an interrupt skips it, and an API error fires StopFailure instead.
With args set, the handler runs in exec form: Claude Code substitutes the project’s root into the path and spawns node directly, with no shell between. Node runs the TypeScript file as it stands, since type stripping is on by default, with no warning, from Node.js 22.18 on the 22 line and from 24.3 on, and reads it as an ES module because the bakery’s package.json sets "type": "module".
// bakery-site/scripts/gate.ts
// The decision behind two hooks: the quick check after a page edit, the full check before the agent stops.
// Exit 0 lets the agent carry on; exit 2 hands stderr back to it to act on.
import { relative } from 'node:path'
export type HookInput = {
readonly hook_event_name: string
readonly cwd: string
readonly tool_input?: { readonly file_path?: string }
readonly stop_hook_active?: boolean
}
export type Check = { readonly ok: boolean; readonly output: string }
export type Shell = { readonly root: string; readonly run: (script: string) => Check }
export type Outcome = { readonly exit: 0 | 2; readonly stdout?: string; readonly stderr?: string }
const pass: Outcome = { exit: 0 }
export const gate = (input: HookInput, shell: Shell): Outcome => {
if (input.hook_event_name === 'PostToolUse') {
const file = relative(shell.root, input.tool_input?.file_path ?? shell.root)
if (!file.startsWith('pages/')) return pass // only a page edit pays for a check
const check = shell.run('check:pages')
return check.ok ? pass : { exit: 2, stderr: `check:pages failed after editing ${file}:\n${check.output}` }
}
if (input.hook_event_name === 'Stop') {
const check = shell.run('check')
if (check.ok) return pass
// The agent gets one more try to fix it. After that the person is told, and the session never loops.
return input.stop_hook_active === true
? { exit: 0, stdout: JSON.stringify({ systemMessage: `npm run check still fails:\n${check.output}` }) }
: { exit: 2, stderr: `npm run check fails, so the work isn't finished:\n${check.output}` }
}
return pass
}
The script Claude Code runs is a thin entry around it. It checks the tree Claude is working in, the git top level of the event’s cwd, since the hooks reference notes that in a worktree CLAUDE_PROJECT_DIR stays at the main checkout while cwd follows Claude. It compares real paths, since a folder reached through a link has two names.
It fails closed. An event it can’t read exits 2 with the reason. An npm it can’t start counts as a failing check: it blocks, and at the second stop the person is told instead.
// bakery-site/scripts/hook-gate.ts
// The script Claude Code runs: read the event, ask the gate, answer with an exit code. It fails closed.
import { spawnSync } from 'node:child_process'
import { readFileSync, realpathSync } from 'node:fs'
import { gate, type HookInput, type Shell } from './gate.ts'
// A folder reached through a link has two names, so paths are compared by their real ones.
const real = (path: string): string => {
try {
return realpathSync(path)
} catch {
return path
}
}
// The tree Claude is working in: the git top level of the event's cwd. In a worktree that is the worktree,
// while CLAUDE_PROJECT_DIR stays at the main checkout. Outside git, the project directory.
const treeOf = (cwd: string): string => {
const top = spawnSync('git', ['-C', cwd, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' })
return top.status === 0 ? top.stdout.trim() : (process.env['CLAUDE_PROJECT_DIR'] ?? cwd)
}
const npm = (root: string): Shell => ({
root,
run: (script) => {
const done = spawnSync('npm', ['run', '--silent', script], { cwd: root, encoding: 'utf8' })
return { ok: done.status === 0, output: done.error?.message ?? `${done.stdout}${done.stderr}`.trim() }
},
})
try {
const input = JSON.parse(readFileSync(0, 'utf8')) as HookInput
const file = input.tool_input?.file_path
const event = file === undefined ? input : { ...input, tool_input: { ...input.tool_input, file_path: real(file) } }
const outcome = gate(event, npm(real(treeOf(input.cwd))))
if (outcome.stdout !== undefined) process.stdout.write(outcome.stdout)
if (outcome.stderr !== undefined) process.stderr.write(outcome.stderr)
process.exitCode = outcome.exit
} catch (error) {
// A gate that can't run blocks: exit 2 hands the reason to the agent instead of letting the turn end quietly.
process.stderr.write(`the hook gate couldn't run: ${error instanceof Error ? error.message : String(error)}`)
process.exitCode = 2
}
Exit codes carry most of a hook’s answer. Zero lets the agent carry on. Two is a blocking error, and what it blocks depends on the event.
After an edit, the tool has already run, so nothing is undone, but the agent reads the stderr and acts on it. At Stop, the turn’s end is blocked, and the agent keeps working. Any other code, one included, is a non-blocking error, and the action goes ahead; a gate that means it exits 2.
A Stop hook that always blocks would send the agent back again and again, so its input carries stop_hook_active, true when the agent is already continuing because of a stop hook. This gate sends the agent back once. The second time, it lets the turn end and tells the person, through a systemMessage, which Claude Code shows to the user.
Claude Code also caps how many times in a row stop hooks may continue a turn: eight, by default.
A Stop hook can also send the agent back with additionalContext, under the same protections, and the transcript then shows it as hook feedback rather than an error. This gate exits 2, because a failing check is a block, not advice.
The pure part, gate, takes the event and a shell, so the tests hand it a shell that answers from a table.
The first feature’s last four scenarios run the real script as Claude Code would, with event JSON on stdin and a project whose page check fails: once directly, once through links, once from a cwd in another tree than CLAUDE_PROJECT_DIR, as in a worktree, and once with an event it can’t read. Each reads back the exit code and the whole of stderr, so a stray warning from Node fails it too.
$ npx vitest run src/extending-the-harness/bakery-site.test.ts --reporter=tree | grep -E 'β|Γ|Tests'
β src/extending-the-harness/bakery-site.test.ts (10 tests) 1006ms
β Feature: The checks the agent can never skip (9)
β Scenario: Given an edit that breaks a page, When the PostToolUse hook runs, Then it exits 2 and names the file 2ms
β Scenario: Given an edit outside pages/, When the PostToolUse hook runs, Then no check runs 0ms
β Scenario: Given the full check fails, When the agent first tries to stop, Then it is sent back with the reason 0ms
β Scenario: Given the check still fails after one more try, When the agent stops again, Then it stops and the person is told 0ms
β Scenario: The project settings send Edit and Write to the gate after each call, and every stop to it too 1ms
β Scenario: Given a project whose page check fails, When the real script reads a PostToolUse event, Then it exits 2 with the report alone 291ms
β Scenario: Given a project and a script reached through links, When the real script runs, Then it still checks the page 277ms
β Scenario: Given a cwd in another git tree than CLAUDE_PROJECT_DIR, as in a worktree, When the real script runs, Then it checks the tree Claude works in 334ms
β Scenario: Given an event the script cannot read, When it runs, Then it exits 2 and says why 98ms
β Feature: A reviewer that reads and never writes (1)
β Scenario: The menu reviewer names itself, says when to use it, and holds read-only tools 1ms
Tests 10 passed (10)
Hooks can live in user settings, in the project’s committed .claude/settings.json, in an uncommitted .claude/settings.local.json, in managed policy, in a plugin, or in a skill’s or a subagent’s frontmatter.
In an interactive session, Claude Code runs no hook from a settings file until the folder’s workspace trust dialog is accepted, so a freshly cloned repository can’t run its hooks unasked. A -p or SDK run treats the folder as trusted, so there the committed hooks run.
The cheapest checks sit closest to the edit. Here the quick page check runs after every page edit and the full check before the agent may stop. The same two tiers suit git hooks, quick before a commit and full before a push, and CI runs the full check again on every push, where nothing on a laptop can switch it off.
Fresh contexts for review
A subagent defined in a file runs its own loop in its own context window, with its own system prompt and its own tools, and hands back only a summary. That makes it a natural reviewer: it starts clean, so it can’t lean on the reasoning that produced the change. A fork, which an interactive session can also spawn, inherits the whole conversation instead, so a review that must start clean goes to a defined subagent. Project subagents are Markdown files in .claude/agents/, and Claude Code decides when to delegate from each one’s description. The bakery’s reviewer is .claude/agents/menu-reviewer.md. See the subagents guide.
---
name: menu-reviewer
description: Reviews a change to the bakery's menu pages against the written scope and the price list. Use after any edit under pages/menu/, before the change is committed.
tools: Read, Grep, Glob
---
You review one change to the bakery's menu pages, and you change nothing.
Read the written scope in docs/scope.md and the price list in data/prices.json first.
Report each finding as the file, the line, and the rule it breaks: a price that
differs from the price list, an item the scope doesn't name, or a heading out of order.
If nothing breaks a rule, say so in one line.
Only name and description are required. The tools line is an allowlist, so this reviewer reads, searches and lists files, and nothing else: a review that can’t write has changed nothing. The bakery’s tests lint the file the way Module 5 lints a skill. Here the lint wants a name, a description that says when, and only reading tools.
Several reviewers raise one more question: whether they favor one another. Andrej Karpathy’s LLM Council sends one question to several models, then hands each model the answers with the models’ names swapped for labels, Response A, Response B and so on, so no model can play favorites as it ranks them. A chairman model writes the final answer.
Shuffling the order too would take away position as a clue; that step is an addition, not part of Karpathy’s version. In Claude Code, each reviewer can be a subagent like the menu reviewer, with the labels put on before any of them reads a word.
The harness you ship grows by composition
A harness built into an app grows by the same rule. Take a client for The tandem harness’s one route, cut down to a single turn, and split it into a core and what plugs into it. The core names what it needs from outside as ports, adapters fill them, and each place that uses the core, here the app and its tests, plugs in adapters of its own. The core imports none of them.
Alistair Cockburn’s hexagonal architecture states the aim: an application driven equally by users, programs, automated tests or batch scripts, and built and tested apart from its eventual devices and databases. Mark Seemann calls the one place the parts are put together a composition root: here the app has one and the tests have one, and each builds its store there and nowhere else.
The core needs two things from outside: a calendar, where the app pencils in an hour the server offered until the customer confirms it, and a transport, which carries each call to the server:
// src/extending-the-harness/core/ports.ts
// What the core needs from outside, as ports. Each composition root passes its own adapters in, from adapters/.
// The app's own calendar, where an offered hour waits for the customer: a map in tests, browser storage in the app.
export type CalendarPort = {
readonly pencil: (customer: string, hour: string) => Promise<void>
readonly penciled: (customer: string) => Promise<readonly string[]>
}
// How a call reaches the server: fetch in a browser, a stub in a test.
export type Call = { readonly method: 'GET' | 'POST'; readonly path: string; readonly body?: unknown }
export type Transport = (call: Call) => Promise<unknown>
// The dependencies that aren't data. The store passes them to every thunk, endpoint and listener as `extra`.
export type Deps = { readonly calendar: CalendarPort; readonly transport: Transport }
Neither can go in state. Redux’s style guide keeps non-serializable values out of state and actions, and the lecture Functional Programming Maintenance Strategy checks the state half in review (Code review checklist). So they travel as the store’s dependencies: the thunk middleware passes them to every thunk, the listener middleware to every listener, and RTK Query to every base query.
A browser can’t keep a secret, so the browser’s transport carries none. The customer’s session rides in a cookie the browser sends itself, and the server decides what each call may do, as the Auth combinator does in The API: Haskell Servant and Nile; servant-auth’s Cookie scheme reads the same session from a cookie:
// src/extending-the-harness/adapters/transport.ts
// The transport adapter: a call, sent to the server over HTTP by whatever send the composition root hands in.
import type { Transport } from '../core/ports.ts'
type Send = (url: URL, init: RequestInit & { headers: Record<string, string> }) => Promise<Response>
type ExtraHeaders = () => Readonly<Record<string, string>>
// A browser can't keep a secret, so the transport carries none. The customer's session rides in a cookie
// the browser sends itself and no script can read, and the server decides what each call may do.
// Any header the server asks every call to carry, the composition root hands in, read again for each call.
export const connect = (server: { readonly baseUrl: string }, send: Send, extra: ExtraHeaders = () => ({})): Transport =>
async ({ method, path, body }) => {
const reply = await send(new URL(path, server.baseUrl), {
method,
credentials: 'include',
headers: { ...extra(), 'content-type': 'application/json' },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
})
if (!reply.ok) throw new Error(`${method} ${path}: HTTP ${reply.status}`)
return reply.json()
}
servant-auth’s Cookie scheme asks one thing more of the client. Beside the session’s cookie it sets an XSRF-TOKEN cookie a script can read, and by default it won’t authenticate any call, a GET included, whose X-XSRF-TOKEN header doesn’t repeat it, so the server’s last clause answers 401, as in The API: Haskell Servant and Nile.
A page on another site can’t read that cookie, so the header proves a call came from the app’s own pages. It is a proof, not a secret, and the session cookie stays HttpOnly, out of every script’s reach. The app’s composition root hands connect the header, read from document.cookie for each call; the tests’ root hands it none.
The session cookie is also SameSite=Lax by default, so a fetch from another site never carries it. The API is served from the same origin as the app, since the token cookie is host-only by default; an API on a sibling host sets servant-auth’s cookieDomain, so the app’s pages can read the token.
Every call goes through the transport. In the browser it wraps fetch, and in a test it answers from a script, so swapping one for the other changes how a call travels and nothing about what it means.
A port gets one set of tests, and every adapter runs them. The calendar has two adapters: one in memory, and one over any Web Storage object, which the app hands window.localStorage and a test hands a small fake:
// src/extending-the-harness/adapters/calendar.ts
// Two adapters for the calendar port. The same tests run against both.
import type { CalendarPort } from '../core/ports.ts'
export const inMemory = (): CalendarPort => {
const hours = new Map<string, readonly string[]>()
return {
pencil: async (customer, hour) => void hours.set(customer, [...(hours.get(customer) ?? []), hour]),
penciled: async (customer) => hours.get(customer) ?? [],
}
}
// Any Web Storage object will do: window.localStorage in the app, a small fake in a test.
export const browserStorage = (storage: Pick<Storage, 'getItem' | 'setItem'>, prefix = 'salon-calendar:'): CalendarPort => {
const penciled = async (customer: string): Promise<readonly string[]> =>
JSON.parse(storage.getItem(prefix + customer) ?? '[]') as string[]
return {
penciled,
pencil: async (customer, hour) => storage.setItem(prefix + customer, JSON.stringify([...(await penciled(customer)), hour])),
}
}
The core keeps a turn’s actions in one slice, so the store can reduce them and an extension can listen for them:
// src/extending-the-harness/core/turns.ts
// Each customer's turn, as plain actions anything may listen for. The salon's customers are invented.
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
export type TurnStatus = 'asking' | 'offered' | 'fell back' | 'no answer'
export type Turns = Readonly<Record<string, TurnStatus>>
type About = { readonly customer: string }
export const turnsSlice = createSlice({
name: 'turns',
initialState: {} as Turns,
reducers: {
askedForHour: (turns, { payload }: PayloadAction<About>) => ({ ...turns, [payload.customer]: 'asking' }),
hourOffered: (turns, { payload }: PayloadAction<About & { readonly hour: string }>) => ({ ...turns, [payload.customer]: 'offered' }),
fellBack: (turns, { payload }: PayloadAction<About>) => ({ ...turns, [payload.customer]: 'fell back' }),
noAnswer: (turns, { payload }: PayloadAction<About & { readonly why: string }>) => ({ ...turns, [payload.customer]: 'no answer' }),
},
})
export const { askedForHour, hourOffered, fellBack, noAnswer } = turnsSlice.actions
The core exports composeStore. A composition root passes its reducers, its dependencies and its listeners, and gets back a store. The same file holds the client’s one RTK Query API slice, whose base query sends every call through the transport.
Redux’s testing guide asks for a separate store in every test, so every call builds a new one, with its own listener middleware, and two tests never share state or a listener:
// src/extending-the-harness/core/store.ts
// The core's store. A composition root extends it with reducers, its dependencies and listeners, and never edits it.
import {
configureStore,
createListenerMiddleware,
type ReducersMapObject,
type ThunkDispatch,
type TypedStartListening,
type UnknownAction,
} from '@reduxjs/toolkit'
import { createApi } from '@reduxjs/toolkit/query'
import type { Call, Deps } from './ports.ts'
import { turnsSlice } from './turns.ts'
type Dispatch = ThunkDispatch<unknown, Deps, UnknownAction>
// Each listener names the part of the state it reads, with startListening.withTypes.
export type StartListening = TypedStartListening<unknown, Dispatch, Deps>
// The client's server calls, as RTK Query endpoints. Each goes through the transport, so a fetch or a stub carries it.
export const api = createApi({
reducerPath: 'api',
baseQuery: async (call: Call, { extra }) => {
try {
return { data: await (extra as Deps).transport(call) } // RTK Query types the thunk's extra argument as unknown
} catch (error) {
return { error: String(error) }
}
},
endpoints: () => ({}),
})
export type Extension<R extends ReducersMapObject> = {
readonly reducers: R
readonly deps: Deps
readonly listen?: (start: StartListening) => void
}
export const composeStore = <R extends ReducersMapObject>({ reducers, deps, listen }: Extension<R>) => {
// A fresh middleware for every store, so two stores never share a listener.
const listeners = createListenerMiddleware<unknown, Dispatch, Deps>({ extra: deps })
listen?.(listeners.startListening)
return configureStore({
reducer: { ...reducers, turns: turnsSlice.reducer, [api.reducerPath]: api.reducer },
middleware: (defaults) =>
defaults({ thunk: { extraArgument: deps } }).prepend(listeners.middleware).concat(api.middleware),
})
}
The app’s composition root is a few lines: the browser’s adapters, the core and one extension. The tests’ root has the same shape, over the in-memory calendar and a scripted server:
// src/extending-the-harness/roots/app.ts
// The app's composition root: the core and the warnings extension, over browser storage and fetch.
// The app hands it window.localStorage and () => document.cookie; a test hands it fakes and a scripted send.
import { browserStorage } from '../adapters/calendar.ts'
import { connect } from '../adapters/transport.ts'
import { composeStore } from '../core/store.ts'
import { warnAfterFallbacks, warningsSlice } from '../warnings/warnings.ts'
type Server = Parameters<typeof connect>[0]
type Send = Parameters<typeof connect>[1]
// servant-auth's Cookie scheme sets an XSRF-TOKEN cookie a script can read, beside the session's,
// and won't authenticate any call, a GET included, whose X-XSRF-TOKEN header doesn't repeat it.
export const xsrfHeader = (cookies: string): Record<string, string> => {
const token = cookies.split('; ').find((c) => c.startsWith('XSRF-TOKEN='))?.slice('XSRF-TOKEN='.length)
return token === undefined ? {} : { 'X-XSRF-TOKEN': token }
}
export const appStore = (
server: Server,
storage: Pick<Storage, 'getItem' | 'setItem'>,
cookies: () => string,
send: Send = (url, init) => fetch(url, init),
) =>
composeStore({
reducers: { warnings: warningsSlice.reducer },
deps: { calendar: browserStorage(storage), transport: connect(server, send, () => xsrfHeader(cookies())) },
listen: warnAfterFallbacks,
})
In the shipped harness, hooks are listeners. The core says what happens as plain actions, asked for an hour, hour offered, fell back and no answer, and an extension listens instead of threading callbacks through the turn’s code. The lecture Modern Redux Architecture Patterns picks a listener for exactly this: behavior that reacts to actions over time (Listener middleware).
This one counts the fallbacks each customer gets, and raises a warning when a customer reaches a count invented for the lesson. Not a line of the core changes:
// src/extending-the-harness/warnings/warnings.ts
// An extension that changes nothing in the core: it counts fallbacks and warns once a customer has had a few.
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
import type { StartListening } from '../core/store.ts'
import { fellBack } from '../core/turns.ts'
export const WARN_AFTER = 3 // invented for the lesson
type Warnings = { readonly fallbacks: Readonly<Record<string, number>>; readonly warned: readonly string[] }
export const warningsSlice = createSlice({
name: 'warnings',
initialState: { fallbacks: {}, warned: [] } as Warnings,
reducers: {
warningRaised: (w, { payload }: PayloadAction<{ readonly customer: string }>) => ({
...w,
warned: [...w.warned, payload.customer],
}),
},
extraReducers: (builder) =>
builder.addCase(fellBack, (w, { payload: { customer } }) => ({
...w,
fallbacks: { ...w.fallbacks, [customer]: (Object.hasOwn(w.fallbacks, customer) ? w.fallbacks[customer]! : 0) + 1 },
})),
})
export const { warningRaised } = warningsSlice.actions
// Listeners run after the reducers, so the count already includes this fallback.
export const warnAfterFallbacks = (start: StartListening) =>
start.withTypes<{ readonly warnings: Warnings }>()({
actionCreator: fellBack,
effect: ({ payload: { customer } }, api) => {
if (api.getState().warnings.fallbacks[customer] === WARN_AFTER) api.dispatch(warningRaised({ customer }))
},
})
$ npx vitest run src/extending-the-harness/composition.test.ts --reporter=tree | grep -E 'β|Γ|Tests'
β src/extending-the-harness/composition.test.ts (10 tests) 83ms
β Feature: The core grows by composition (6)
β Scenario: Given two stores built for tests, When one customer gets the fallback in one, Then the other never hears of it 61ms
β Scenario: Given fallbacks for one customer, When their count reaches the limit, Then one warning is raised for that customer alone 7ms
β Scenario: Given customers named like built-ins, When their count reaches the limit, Then each gets one warning 6ms
β Scenario: Given a server that answers with something else, When the turn runs, Then it has no answer and nothing is penciled in 2ms
β Scenario: Given the app's store over a storage, When an hour is offered, Then it is penciled into that storage 2ms
β Scenario: Given the server's XSRF-TOKEN cookie, When the app's store sends a call, Then the call repeats it in the X-XSRF-TOKEN header 2ms
β Feature: The calendar port, in memory (2)
β Scenario: Penciled hours come back in the order they were penciled 0ms
β Scenario: Two customers never share hours 0ms
β Feature: The calendar port, browser storage (2)
β Scenario: Penciled hours come back in the order they were penciled 0ms
β Scenario: Two customers never share hours 0ms
Tests 10 passed (10)
The third scenario’s customers are called constructor and __proto__, names every plain object already answers to through its prototype. The count reads only a key the record holds itself, so each still gets one warning. The fifth builds the app’s store over a fake storage and finds the offered hour penciled into it. The sixth finds the cookie’s token in each call’s header, read again after the cookie changes, and the port’s tests run once per adapter. The lecture’s testing architecture gives listeners tests of their own, for what they match and what they emit, and the warning scenario is one.
New rules go to the server
A booking app could take its rules as callbacks, each client registering its own party-size check. Then a second client, a phone app say, forgets one, and the two disagree about the same booking. So the rules live on the server, in one list, and every client posts a booking to the same route. The server decides, as in Server and client in tandem, and a rule is a decision.
The Haskell here compiles under the course’s cabal file, which turns on GHC2021 for every module, plus DataKinds, DeriveGeneric, DerivingStrategies, LambdaCase, OverloadedStrings and TypeOperators. A module copied into a fresh project needs the same settings, or LANGUAGE pragmas of its own.
-- extending-the-harness/SalonRules.hs
-- The salon's booking rules, in one place on the server, and one check every client calls.
-- The chairs and the hours are invented for the lesson.
module SalonRules (Booking (..), Checked (..), rules, check) where
import Data.Aeson (FromJSON, ToJSON (..), object, (.=))
import Data.Text (Text)
import GHC.Generics (Generic)
data Booking = Booking {party :: Int, hour :: Int}
deriving stock (Generic, Show, Eq)
instance FromJSON Booking
instance ToJSON Booking
-- Accepted, or refused with every rule the booking breaks, never just the first.
data Checked = Accepted | Refused [Text]
deriving stock (Show, Eq)
instance ToJSON Checked where
toJSON = \case
Accepted -> object ["status" .= ("accepted" :: Text)]
Refused broken -> object ["status" .= ("refused" :: Text), "broken" .= broken]
-- Each rule: what it says, and whether a booking keeps it.
rules :: [(Text, Booking -> Bool)]
rules =
[ ("a booking needs at least one person", \b -> party b >= 1)
, ("the salon has 6 chairs", \b -> party b <= 6)
, ("bookings start between 9:00 and 14:00", \b -> hour b >= 9 && hour b <= 14)
]
check :: Booking -> Checked
check b = case [rule | (rule, keeps) <- rules, not (keeps b)] of
[] -> Accepted
broken -> Refused broken
check answers one of two ways. Accepted lets the app book it. Refused lists every rule the booking breaks, so the customer fixes the booking once rather than once per rule. The route stays as thin as the handlers in The API: Haskell Servant and Nile:
-- extending-the-harness/SalonCheckApi.hs
-- One route: post a booking, get the check back. Every client calls this one.
module SalonCheckApi (BookingCheckAPI, app) where
import SalonRules
import Servant
type BookingCheckAPI = "bookings" :> "check" :> ReqBody '[JSON] Booking :> Post '[JSON] Checked
app :: Application
app = serve (Proxy @BookingCheckAPI) (pure . check)
$ cabal test extending-the-harness --test-show-details=direct 2>&1 | grep -E 'β|β|examples|Feature|OK'
Feature: The salon's booking rules live on the server
Scenario: Given a party of nine at 10:00, When it is checked, Then it is refused for the chairs [β]
Scenario: Given a party of nine at 16:00, When it is checked, Then it is refused for the chairs and the hours at once [β]
Property: a booking is accepted exactly when 1 to 6 people come from 9:00 to 14:00, and a refusal names every bound it breaks [β]
+++ OK, passed 100 tests.
Scenario: Every reply the client's tests read is what check encodes for its booking [β]
Feature: Every client checks against the same route
Scenario: The app posts a booking and gets the check back [β]
Scenario: A booking the decoder can't read is refused before any rule runs [β]
6 examples, 0 failures
The property matters most. It states the bounds again on its own, one to six people from 9:00 to 14:00, and over random bookings checks that check accepts exactly those and refuses the rest with every bound they break, no more and no fewer. A wrong rule in the list fails it, so the reasons a customer reads are the real ones.
The runs here come from a UTF-8 terminal; under the POSIX locale, Hspec marks a pass [v] where they show ✔.
A new question, from one list
The booking route in Server and client in tandem asks the customer one question: which time. Say the salon now wants to ask which stylist, too. On the client, two places need the new question: the decoder, which must accept its name, and the screens, one for each question.
The wire lesson already shows the move that keeps such places together. List the names once, as const, and derive the type from the list. isQuestion, the guard a decoder uses, checks a name against the list, and the screens are a record over the type, so the compiler holds every screen to the list:
// src/extending-the-harness/questions/questions.ts
// Every question the booking server may ask the customer, listed once. The type comes from the list.
export const QUESTIONS = ['time', 'stylist'] as const
export type Question = (typeof QUESTIONS)[number]
export const isQuestion = (value: unknown): value is Question => QUESTIONS.some((q) => q === value)
// src/extending-the-harness/questions/screens.ts
// One screen for each question. The record's type is the list, so a question without a screen doesn't compile.
import { isQuestion, type Question } from './questions.ts'
export type Screen = { readonly title: string; readonly choices: readonly string[] }
export const screens: { readonly [Q in Question]: (choices: readonly string[]) => Screen } = {
time: (choices) => ({ title: 'Which time suits you?', choices }),
stylist: (choices) => ({ title: 'Who would you like?', choices }),
}
// A question the server sends arrives as text; only a name on the list gets a screen.
export const screenFor = (name: unknown, choices: readonly string[]): Screen | string =>
isQuestion(name) ? screens[name](choices) : `no screen for the question ${JSON.stringify(name)}`
Make the change the way it usually starts, in the list. Add 'stylist' to QUESTIONS before writing its screen, and the compiler names the place that lacks it:
$ npx tsc --noEmit -p .
src/extending-the-harness/questions/screens.ts(7,14): error TS2741: Property 'stylist' is missing in type '{ time: (choices: readonly string[]) => { title: string; choices: readonly string[]; }; }' but required in type '{ readonly stylist: (choices: readonly string[]) => Screen; readonly time: (choices: readonly string[]) => Screen; }'.
Add the screen, and every check passes. A name the list doesn’t hold still gets no screen at all:
$ npx vitest run src/extending-the-harness/questions.test.ts --reporter=tree | grep -E 'β|Γ|Tests'
β src/extending-the-harness/questions.test.ts (3 tests) 7ms
β Feature: A new question, from one list (3)
β Scenario: Given every question the list names, When the screens are counted, Then each has exactly one 3ms
β Scenario: Given the stylist question, When it arrives, Then the screen asks who 1ms
β Scenario: Given a question the list does not name, When it arrives, Then no screen is shown 1ms
Tests 3 passed (3)
On the client there is no second copy of the names to fall out of step: the list is the one source, and the compiler reads it.
The server still names the questions it asks. The contract check in One contract, checked from both sides catches a server that stops sending a question the client reads. A question the server adds before the client lists it arrives as a name off the list, and gets no screen.
Every extension carries a scenario
An extension without a scenario is a claim. The rules extension’s scenarios live in rules.feature, written in Gherkin as Given-When-Then (Gherkin) syntax in BDD teaches, and run as Vitest tests named after them, through the real store, endpoint and decoder:
# src/extending-the-harness/rules/rules.feature
Feature: The salon's booking rules live on the server
Every client sends a booking to one route, and gets back accepted,
or refused with every rule the booking breaks.
Scenario: A party that fits the chairs, at an hour the salon books, is accepted
Given a party of 2 at 10:00
When the app checks the booking
Then it is accepted
Scenario: A party larger than the chairs is refused, with the rule
Given a party of 9 at 10:00
When the app checks the booking
Then it is refused, because the salon has 6 chairs
Scenario: A booking that breaks two rules is refused with both
Given a party of 9 at 16:00
When the app checks the booking
Then it is refused for the chairs and for the hours, in one reply
Scenario: A booking for nobody is refused
Given a party of 0 at 10:00
When the app checks the booking
Then it is refused, because a booking needs at least one person
// src/extending-the-harness/rules/bookingCheck.ts
// The rules extension: the app asks the server to check a booking before it acts on one.
import { api } from '../core/store.ts'
export type Booking = { readonly party: number; readonly hour: number }
export type Checked = { readonly status: 'accepted' } | { readonly status: 'refused'; readonly broken: readonly string[] }
// Strict, at the boundary: a reply that isn't one of the two shapes never reaches the cache.
export const decodeChecked = (raw: unknown): Checked => {
const r = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>
const broken = r['broken']
if (r['status'] === 'accepted') return { status: 'accepted' }
if (r['status'] === 'refused' && Array.isArray(broken) && broken.length > 0 && broken.every((b) => typeof b === 'string'))
return { status: 'refused', broken }
throw new Error(`not a checked booking: ${JSON.stringify(raw)}`)
}
export const bookingCheckApi = api.injectEndpoints({
endpoints: (build) => ({
checkBooking: build.mutation<Checked, Booking>({
query: (booking) => ({ method: 'POST', path: 'bookings/check', body: booking }),
transformResponse: decodeChecked,
}),
}),
})
One more test reads the feature file and fails when a scenario has no test, the move Geometric reasoning as data makes for its families. Write the scenario first, and the run says what’s missing:
$ npx vitest run src/extending-the-harness/rules.test.ts 2>&1 | grep -E 'Γ|^Error|Tests'
Γ Scenario: A booking for nobody is refused 3ms
Γ has a test for every scenario in rules.feature 4ms
β―β―β―β―β―β―β― Failed Tests 2 β―β―β―β―β―β―β―
Error: no test for the scenario "A booking for nobody is refused"
Tests 2 failed | 3 passed (5)
Write its test and the run passes. Every reply the scenarios decode comes from replies.json, which the Haskell spec pins to what check encodes for each booking, and the stub answers only a POST to bookings/check. So the client is held to the route and to the shapes the server really sends:
$ npx vitest run src/extending-the-harness/rules.test.ts --reporter=tree | grep -E 'β|Γ|Tests'
β src/extending-the-harness/rules.test.ts (5 tests) 63ms
β Feature: The salon's booking rules live on the server (5)
β Scenario: A party that fits the chairs, at an hour the salon books, is accepted 53ms
β Scenario: A party larger than the chairs is refused, with the rule 3ms
β Scenario: A booking that breaks two rules is refused with both 2ms
β Scenario: A booking for nobody is refused 2ms
β has a test for every scenario in rules.feature 0ms
Tests 5 passed (5)
From there, every extension climbs the same rungs of checks as everything else. From fine-tune to release draws the three rungs, with a trained model on them.
A site repository’s harness, move by move
Back to the bakery’s repository, as it stood before the hooks and the reviewer. It starts modestly, as most do:
- Always on. A
CLAUDE.mdwith the site’s conventions, pointing to the written scope. - Know-how. Two project skills: one written for the site, and one copied from a public plugin.
- A pre-launch checklist in the README: what a person checks before a page goes live.
- Gates as scripts in
package.json, run before a push and again in CI. - Not yet: hooks, MCP configuration, project subagents.
Each move into the harness is an option, taken when its trigger shows up. The gates become the two hooks above, so a turn that ends on a failing check gets one more try first and then tells the person; CI stays, because a hook runs only where Claude Code runs. The checklist’s review becomes a project subagent, starting clean.
The checklist becomes a skill once it’s pasted into chat a third time. A design tool whose data gets copied over by hand becomes an MCP server. A second site that needs the same setup gets it as a plugin, with the conventions from its CLAUDE.md carried as a skill.
Python, the team’s way opens the module’s Python lessons, where the model work lives, with the same habit of a contract at every boundary.