Patterns in agent harnesses
An agent harness wraps a model in an execution loop that feeds it context and tools at each step, and hooks fire at fixed points in that loop to change what happens. The loop's shape is fixed, so attaching code at those points is the only way in.
There are two kinds of point. A wrap hook surrounds a single model or tool call, so it can run before that call, after it, or in its place. A node hook sits between loop stages and reads the running state, returning an update before the next stage starts.
Both kinds are instances of patterns that software engineering catalogued decades ago.1 This post works through the two points and the patterns each carries, including where the textbook label stops fitting.
The frame: Template Method
Template Method is the pattern where a base class fixes the skeleton of an algorithm and leaves named steps for subclasses to fill in, so the overall sequence holds still while the details vary.
The agent loop is that skeleton: it starts, then repeatedly calls the model, decides on tools, and runs them until a stop condition, then ends. The hook points are the slots the harness leaves open for middleware to fill.
async function runAgent(state) {
state = await beforeAgent(state)
while (true) {
state = await beforeModel(state)
state = await callModel(state) // wrapModelCall hooks wrap this step
state = await afterModel(state)
if (!hasToolCalls(state)) break
state = await runTools(state) // wrapToolCall hooks wrap this step
}
return afterAgent(state)
}
Because the skeleton never changes, every added behavior enters through a hook: logging, retry, model routing, and context compaction are middleware, ordered by which hooks run and when. Changing the loop itself means editing the graph by hand.
Wrap hooks: interception around a call
A wrap hook is handed the request plus a handler callback that runs the wrapped operation, and it can call handler before doing its own work, after it, or not at all. The harness exposes two, wrapModelCall and wrapToolCall.
This is the Interceptor pattern, known from Core J2EE Patterns and, earlier, as the around advice of aspect-oriented programming, where code wraps a join point and decides whether the call runs. Calling handler any number of times is the whole point.
const retryModel = createMiddleware({
name: "RetryModel",
wrapModelCall: async (request, handler) => {
for (let i = 0; i < 3; i++) {
try { return await handler(request) }
catch (e) { if (i === 2) throw e }
}
throw new Error("Unreachable")
},
})
How many times the hook delegates separates its uses. A cache hit returns without ever calling handler, a retry wrapper calls it again when the model fails, and a pass-through calls it once and edits the result.
When several wrap hooks register they nest, the outermost running first and getting control back last. Each can change the request before delegating and the response after, so the outermost layer, returning last, has the final say over the response.
Decorator and Strategy on the wrap hook
Two familiar patterns are just the wrap hook used in a constrained way. Decorator wraps an object, keeps its interface, and delegates exactly once while adding behavior around the call, which is what a pass-through wrap hook does.
Retry and caching are not Decorator, because Decorator always delegates once, so a hook that skips the call or repeats it is showing the broader Interceptor shape. Decorator is the corner where the delegation count is pinned to one.
Strategy is the same hook seen from another angle, since it picks one of several interchangeable algorithms at runtime behind a fixed interface, and a wrap hook does that by handing handler a request with one field swapped.
const routeModel = createMiddleware({
name: "RouteModel",
wrapModelCall: (request, handler) => {
if (request.messages.length > 10) {
return handler({ ...request, model: complexModel })
}
return handler({ ...request, model: simpleModel })
},
})
The same move swaps the tool set or system prompt by spreading { ...request, tools } or { ...request, systemPrompt }, and the downstream call looks identical. It is one use of the wrap hook: selection written as a modified request.
Node hooks: stages between steps
A node hook runs in the gaps between loop stages: beforeAgent, beforeModel, afterModel, and afterAgent. Each receives state and returns an update merged back before the next stage. Before hooks fire in registration order, after in reverse.
Run in order, these hooks form a pipeline whose payload is the state, and each stage transforms it before handing it on. The sequence runs before-agent once, then cycles before-model, model call, after-model, and tools, ending with after-agent.
const contextPin = createMiddleware({
name: "ContextPin",
beforeModel: (state) => {
const last = state.messages[state.messages.length - 1]
return {
messages: [new SystemMessage({
id: "context",
content: `Context for: ${last.content}`,
})],
}
},
})
Because each stage reads what the previous one wrote, order changes the result: put summarization ahead of memory and it truncates history before memory records it. Nothing isolates one hook's edits from another's, so they share one mutable state.
A hook that touches a field therefore has to assume earlier hooks already touched it, and the two patterns that follow both lean on this shared, unguarded state.
Observer: the read face
Some node hooks read without writing, which matches Observer: the pattern lets observers watch a subject change while it stays ignorant of what they record. Logging, latency, and token counting all watch every pass without altering it.
const logging = createMiddleware({
name: "Logging",
beforeModel: (state) => {
console.log(`Messages: ${state.messages.length}`)
},
afterModel: (state) => {
const last = state.messages[state.messages.length - 1]
console.log(`Response: ${last.content}`)
},
})
These hooks run on every pass and return no update, so the observer chooses what to record while the event happens regardless. This is the one node-hook role that lines up with textbook Observer.
The match lasts only as long as the hook keeps quiet, because the instant it returns an update it is transforming state and has become a pipeline stage. True Observer means reading without writing, which here is the narrow set of logging-style hooks.
Short-circuit: routing with jumpTo
Chain of Responsibility strings handlers together and lets each one deal with the request or pass it down the line, with the classic version stopping once a handler takes ownership. Node hooks get a short-circuit version through jumpTo.
A hook sets jumpTo to end, tools, or model, and the engine routes straight there, skipping the nodes between. The simple case jumps to end to halt: a step limiter stops a runaway loop, a budget guard ends once spending crosses a cap.
const stepLimiter = createMiddleware({
name: "StepLimiter",
afterModel: (state) => {
if (state.messages.length > 20) {
return { jumpTo: "end" }
}
},
})
Jumping to end is the clean case because the loop just stops. Redirecting to model or tools is trickier: the model's last turn may hold unanswered tool calls, so a hook that loops back has to repair the messages first or the next call is malformed.
Ordering turns into a security property, because before hooks run in registration order and a hook that sets jumpTo cuts off every later hook. A guard registered too late can be jumped over by an earlier hook that already routed away.
Still, the fit is imperfect. No hook claims the request the way a CoR handler does; each one gets the state and usually just transforms it, and jumpTo only redirects to a fixed node. They share only the idea of an early exit.
What the frame forbids
The fixed skeleton draws a hard line around what a hook can do: it sees state at one point and can shape only what comes after, never reaching back into stages that have already run.
An afterModel hook can add messages or redirect the loop before it moves on, but it cannot un-spend the call, because the tokens are already burned and the request already reached the provider.
The whole catalog reduces to two interception points inside one Template Method loop. Wrap hooks give Interceptor, with Decorator and Strategy as constrained corners; node hooks give Pipeline, with Observer and the jumpTo chain as two uses.