Part III Chapter 9: A Word at the Critical Moments — Hooks

Chapter guide: The first extension chapter of Part III · Build It. mini-agent currently has just one approval seam,
BeforeToolCall. This chapter reasons outward from it into a full lifecycle hook system: it does not write intosrc, but spells out what the interface should look like, how to define failure semantics, and how to design the tests. The precondition is to confirm the requirements first, then act.
Xiao A's Agent ran all afternoon. Staring at the logs, he suddenly asked Lao Z: "I want to write a line to the books before every tool call, print a status line at the end of each turn, and do some cleanup both when the Agent starts and when it ends. The current BeforeToolCall can only intercept tools. At every other moment I can do nothing."
"What you want to add is lifecycle hooks," Lao Z said. "But don't rush to write. Answer three questions first: which of these moments does someone need to observe? On failure, should we abort or ignore? Do we want any hook to rewrite the main path?"
Xiao A: "Why do those three questions have to be answered before writing code?"
"Because their answers decide the shape of the interface," Lao Z said. "mini-agent now has only one BeforeToolCall (see types.ts), whose signature is (call: ToolCall) => Promise<{approved: boolean; reason?: string}>. It has a return value because it decides whether the main path runs. The bookkeeping and status printing you want don't decide whether it runs; their only job is to 'see'. If you shove bookkeeping into BeforeToolCall, the return value approved gets shared by two things: the approval decision, and 'did the bookkeeping finish?'. Squeeze two kinds of need into one return value, and sooner or later someone will change the approval logic's return value for the side effect of bookkeeping — that is where an interface starts to corrode. So before you act, sort out which moments need to 'see' and which moments need to 'decide'."
Tell the Three Kinds of Hook Apart First
Part I · Concepts, Chapter 11: A Word Before the Action — Hooks, divides Hooks into three kinds. When you implement, you first decide which kind each one lands in:
- Subscriber: only observes, never changes the main path. Logging, metrics, and auditing belong to this kind.
- Interceptor: can allow, reject, or rewrite the action.
BeforeToolCallbelongs to this kind.- Transformer: rewrites the input but does not decide whether to continue. Context transformation belongs to this kind.
"These three kinds have completely different failure semantics," Lao Z said. "A subscriber throwing loses at most one log line; an interceptor throwing can fail the whole turn; a transformer throwing sends the request out with bad context. Classify before you design the interface, or you'll mix three failure semantics into a single interface."
Lay the three kinds side by side along a few dimensions:
| Kind | Role | Returns what | Failure semantics | Where it sits | Example |
|---|---|---|---|---|---|
| Subscriber | Observe, don't change the main path | No result | Swallow and record | Side path | Logging, metrics, auditing |
| Interceptor | Allow / reject / rewrite the action | A decision result | Propagates upward | Main path | BeforeToolCall |
| Transformer | Rewrite the input, don't decide whether to continue | The rewritten input | Request goes out with bad context | Main path | Context transformation |
"Notice that the interceptor and the transformer both sit on the main path, yet return differently: the interceptor gives 'whether to run', the transformer gives 'what it becomes'. A subscriber's only output is a side effect, so its failure can only affect itself. This table is the skeleton of the chapter; every design that follows fills in one of its cells."
Xiao A: "The single seam mini-agent has now — which of the three kinds is it?"
"Only one, and it's an interceptor," Lao Z said. "BeforeToolCall is called by runAgent before each tool executes (see options.beforeToolCall?.(call) in agent.ts), and its return value directly decides whether the tool runs: !approval.approved means continue, skipping execution. Its failure also propagates upward. A test case named approval hook failures close every persisted call before rethrowing (see tool-failure-session.test.ts) shows this: when the hook throws, the current call is recorded as 'tool approval failed', the calls after it are recorded as 'not executed after an earlier failure', and then the error is rethrown to the caller unchanged. Fail and stop; every unfinished call gets closed out as a failure. That is the interceptor's failure semantics. The bookkeeping hook you want must not go this route — a bookkeeping failure should not crash the whole turn."
Design a Minimal Set of Lifecycle Hooks
All of Xiao A's needs (bookkeeping, status printing, cleanup) are subscribers. Lao Z reasoned through a set of interfaces along the pattern of BeforeToolCall:
// Design sketch: lifecycle hooks (not in src; confirm the requirements first)
export interface TurnHooks {
onTurnStart?(context: { user: string }): Promise<void> | void;
onBeforeToolCall?(call: { id: string; name: string; input: unknown }): Promise<void> | void;
onAfterToolCall?(result: { callId: string; isError: boolean }): Promise<void> | void;
onTurnEnd?(result: { stopReason: "complete" | "max_turns" | "aborted" }): Promise<void> | void;
}"Note two things," Lao Z said. "First, every method is optional — you don't have to implement all of them. Second, each takes minimal context, not the entire internal state. The less information a subscriber receives, the less tempted it is to change the main path."
Map these four methods onto runAgent's loop and see at which moment each fires:
The execution order of one runAgent round (mini-agent's current loop + the rehearsed hook trigger points)
onTurnStart({ user }) ← record the turn start
│
▼
client.stream(...) ──► collect text / tool_call
│
▼
for each call:
BeforeToolCall(call) ──► approved? ──no──► record tool error, skip
│ yes
onBeforeToolCall(call) ← record "this call is about to happen"
│
▼
tool.execute(...)
│
onAfterToolCall({ callId, isError }) ← record the call result
│
▼
onTurnEnd({ stopReason }) ← complete / max_turns / aborted"Note that half of this diagram is dashed," Lao Z said. "mini-agent's loop now calls only options.beforeToolCall?.(call); none of the four new hooks is wired up. This diagram is a rehearsed wiring plan, not implemented behavior. The point of drawing it is to let you see: each hook is nailed to one definite moment. onBeforeToolCall must land after the interceptor's decision and before the tool executes; onTurnEnd comes after the stopping judgment. Get the moment wrong, and the subscriber records false books."
Xiao A tried to ask for more: "How do you define 'minimal context'? Can onTurnEnd take the whole messages array?"
"It runs, but the trouble is large," Lao Z said. "The whole messages is runAgent's internal state, carrying every user / assistant / tool message, including the raw context not yet trimmed. Hand that to a subscriber, and it will inevitably start thinking 'let me edit one message' or 'let me make a decision from the history'. The moment a subscriber starts changing messages, it is no longer a subscriber — it's a transformer with no defined failure semantics. The context you give a subscriber should be exactly enough to keep the books, and not enough to intervene. { stopReason } is enough for onTurnEnd: it knows whether this round was complete or max_turns, and doesn't need to know which tools were called before. This matches mini-agent's design for ToolResult: the input a tool gets is the parameters the model supplied, not the whole loop state (see execute(input, signal) in tools.ts). Narrowing the interface guards against future overreach."
Xiao A: "What's the relationship between onBeforeToolCall and the existing BeforeToolCall?"
"Different division of labor," Lao Z said. "BeforeToolCall is an interceptor; it returns { approved, reason } to decide whether to let the call through. onBeforeToolCall is a subscriber; it only records, returning no result. One is on the main path, the other on the side path. Stuffing logging logic into BeforeToolCall turns side-path observation into a main-path blocking point — that's the 'observer with a timing constraint' trap the Concepts part talks about, and in the implementation it slows the whole loop."
Xiao A: "Should hooks also take an AbortSignal? Doesn't Tool.execute get one? What if a hook is halfway through and the user hits Ctrl-C."
"That's the parameter most easily over-given during the rehearsal stage," Lao Z said. "runAgent checks signal everywhere, and Tool.execute takes it too — but that's to abort a side effect that might run a long time. If a subscriber only keeps the books, it returns in a few milliseconds; giving it a signal just stuffs it with something it can't use. If it needs to report over the network, timeout and retry are its own business — the loop shouldn't solve them for it. More parameters aren't more robust; a parameter counts only when it 'has a caller and has semantics'. Add it back only when you get evidence that 'a subscriber might hang' — that is the 'proven-needed moment'."
Xiao A: "For the same tool call, which runs first — onBeforeToolCall or BeforeToolCall?"
"BeforeToolCall runs first; it decides whether to let the call through. Only once it's let through does the tool execution come around," Lao Z said. "Look at the order in agent.ts: first options.beforeToolCall?.(call) to get the approval; if !approval.approved, continue skips it; only once approved does tool.execute run. If onBeforeToolCall jumped ahead, it would first record 'about to call bash', then bash gets denied by denyBashByDefault, and the log no longer matches reality. Write the order into a comment; don't rely on whoever comes later to guess it."
Failure Semantics: Subscribers Must Not Drag Down the Main Path
Xiao A: "What if the network report inside onAfterToolCall fails? Should the Agent stop?"
"A subscriber's failure should not abort the main path," Lao Z said. "It either gets swallowed by try/catch and recorded, or goes into a separate async queue. Escalating a subscriber failure into a main-path failure amounts to letting the logging system decide whether the Agent can keep running."
// Design sketch: subscriber failure does not drag down the main path
for (const hook of hooks) {
try {
await hook.onAfterToolCall?.(result);
} catch (error) {
log.warn("lifecycle hook failed", { hook: "onAfterToolCall", error });
// Do not abort the loop — a logging failure should not stop the Agent
}
}"But the interceptor is different," Lao Z emphasized. "BeforeToolCall's failure must propagate upward, because 'the approval logic errored out' is itself a state that needs handling — letting it through silently amounts to bypassing the security policy. Subscribers swallow errors; interceptors report them. Write this dividing line into a comment, or whoever comes later will blur it."
Xiao A pressed on the details: "A subscriber swallows the error — swallows it to where? Just a console.warn?"
"console.warn is the bare minimum. In production you write it to a separate audit queue or a log on disk," Lao Z said. "The key is: never let it into messages. mini-agent's messages is the source of the model's context (see trimContext(messages, ...) feeding client.stream in agent.ts). Once a hook failure's details get written in, the model sees them and may adjust its behavior accordingly. A hook failure is a host-side matter, unrelated to the model, so the place it's swallowed must be outside the loop, and the content swallowed must not flow back into the context. A test example states the same principle: a tool throwing after cancellation records a diagnostic result (see cancellation.test.ts) — an error a tool throws after cancellation gets replaced with 'aborted during execution', and the test asserts that the original 'sensitive late failure' text never appears in messages."
Xiao A thought of another case: "What if a subscriber doesn't throw but simply hangs? Say a network report never returns, the hook never comes back — doesn't the main path deadlock?"
"That's the pit subscribers most easily fall into," Lao Z said. "Two fixes: one, put a timeout on the subscriber call and treat a timeout as a failure to swallow; two, make the subscriber non-blocking — toss the data it needs to record into a queue and return, doing the real report in the background. The subscriber's contract is 'I've noted it down', not 'I've reported it'. mini-agent's record function (see agent.ts) is a contrast: its await appendSession writes synchronously to disk, because the session is part of the main path. But a subscriber should not copy this — a subscriber should fire-and-forget. Every await on the main path has to earn its keep; a subscriber doesn't qualify."
Xiao A: "Should 'no hook installed' and 'hook failed' also be kept semantically separate?"
"They must be, and the tests should have one case each," Lao Z said. "BeforeToolCall uses options.beforeToolCall?.(call) ?? { approved: true } (see agent.ts): not installed means 'no policy, allow by default' — that's a design gap, and the test should let you see it. Installed but throwing means 'the policy errored, propagate upward' — that's a fault, and the test asserts it stops. Mix the two semantics and you'll keep oscillating between 'why did it run bare' and 'why did it stop'. The same goes for subscribers: a turn with no hook attached and a turn with one attached should produce identical messages, the difference living only in the side-path effect, not on the main path."
Both approaches carry a cost — don't look only at the benefits:
| Approach | How | Benefit | Cost |
|---|---|---|---|
| Add a timeout | Promise.race([hook(), timeout]) | Simple and direct | The timeout is only a backstop; the hook still occupies resources in the background |
| Async queue | The hook returns immediately; data goes into a queue | Zero wait on the main path | You must handle queue backlog and flushing on process exit |
"The second approach has an easy-to-miss cleanup question: when the process is about to exit, should the audit data in the queue be flushed? If so, who flushes it, and when? This is no longer the hook's responsibility; it's the host lifecycle's responsibility — so it goes into the acceptance criteria, not just an interface comment. However well the subscriber is designed, if no one flushes the queue on exit, the books are still lost."
Xiao A: "The dividing line 'subscribers swallow errors, interceptors report them' — what guarantees whoever comes later won't blur it?"
"Three layers," Lao Z said. "The first is the interface signature: a subscriber's methods declare no return type for an approval result, so you can't return a 'let it through' from the side path even if you wanted. The second is the comment: the dividing line is written into the interface docs, annotating each kind's failure semantics. The third is the test: use a deliberately throwing subscriber to assert the main path was unaffected, turning 'must not drag down the main path' into a regression case rather than a verbal agreement."
Testing Essentials
Lao Z listed a test checklist for reasoning through this set of hooks:
- Each hook fires once, in order;
onTurnStartbefore the first model call,onTurnEndafter the stopping judgment.- A subscriber throwing does not change the stop reason:
completestayscomplete.onBeforeToolCallreturns no value and cannot block: its signature has noblockfield at all.- Drive a full turn with a fake
ModelClient; assert the hook call sequence matches the log.
Xiao A: "The second one, 'a subscriber throwing does not change the stop reason' — how do you assert that?"
"Use a fake client to produce a turn with a known outcome, then make the subscriber deliberately throw, and compare whether the stop reason changed," Lao Z said. "mini-agent's tests work exactly this way. Look at writes through a tool-call loop in agent.test.ts: a FakeClient feeds two rounds of events (one tool_call, one text), and the test asserts stopReason === 'complete'. Attach your subscriber, make it throw new Error('log failed') inside onAfterToolCall, and run the same turn again — stopReason must still be complete, and the contents of messages must be identical to the case with no subscriber attached. Same input, same output, no matter what the subscriber did — that's the falsifiable assertion for 'the subscriber doesn't affect the main path'."
"How do you test the order?" Xiao A pressed.
"Collect the hook names in an array," Lao Z said. "Each hook pushes its own name into the array; after the turn ends, assert the array equals ['onTurnStart', 'onBeforeToolCall', 'onAfterToolCall', 'onTurnEnd']. Note that onBeforeToolCall must come after BeforeToolCall: when both the approval hook and the subscriber hook are attached, the approval's decision must appear first in the array. Order is part of the contract, not an implementation detail. mini-agent doesn't have this set of hooks; this is only a test draft for the rehearsal stage, not something npm test runs."
// Test draft: subscriber failure does not change the stop reason
const order: string[] = [];
const hooks = {
onTurnStart: async () => { order.push("onTurnStart"); },
onBeforeToolCall: async () => { order.push("onBeforeToolCall"); },
onAfterToolCall: async () => {
order.push("onAfterToolCall");
throw new Error("log failed"); // Deliberately throw
},
onTurnEnd: async () => { order.push("onTurnEnd"); },
};
const result = await runAgentWithHooks({
client: new FakeClient([
[toolCall("work"), { type: "text", text: "done" }],
]),
hooks,
});
assert.equal(result.stopReason, "complete"); // Unchanged
assert.deepEqual(order, ["onTurnStart", "onBeforeToolCall", "onAfterToolCall", "onTurnEnd"]);"This sketch has two assertions: one verifies order, one verifies failure isolation," Lao Z said. "onAfterToolCall deliberately throws, but stopReason is still complete — if a hook failure really dragged down the main path, this line turns red. Note that it doesn't assert the contents of messages; you should add that: deepEqual the result messages against a control turn with no hooks attached, locking down what the model saw as well. One assertion watches the control flow, one watches the data flow — you need both."
Comparison with pi-mono
"pi-mono's low-level loop has a more complete hook set," Lao Z said, opening the appendix Extension Mechanism Addendum: The Interface Boundaries of Hooks. "convertToLlm, transformContext, beforeToolCall, afterToolCall, shouldStopAfterTurn, prepareNextTurn — they fall into the three kinds of interceptor, transformer, and subscriber respectively. mini-agent's rehearsal is the minimal slice of that set: subscribers only, with the interceptor left to BeforeToolCall. Don't copy the full set verbatim; start from the two moments you've proven you need."
Unfold pi-mono's six hooks along the three kinds:
| pi-mono hook | Kind | Does what | mini-agent counterpart |
|---|---|---|---|
convertToLlm | Transformer | Converts internal messages to provider format | toAnthropicMessages (built-in function) |
transformContext | Transformer | Rewrites the messages sent to the model | trimContext (built-in function) |
beforeToolCall | Interceptor | Allows / rejects tool calls | BeforeToolCall (implemented) |
afterToolCall | Subscriber | Observes tool-call results | None |
shouldStopAfterTurn | Interceptor | Decides whether the turn stops | None (calls.length === 0, built in) |
prepareNextTurn | Subscriber | Prepares the next round's input | None |
"In this table, mini-agent has three cells reading 'None'," Lao Z said. "But 'None' doesn't mean missing. The genuinely empty subscriber slots are only afterToolCall and prepareNextTurn, and the bookkeeping and status printing you want fall exactly into the empty slots. The other three cells' responsibilities are already occupied by built-in functions and simple stopping logic."
Xiao A wasn't satisfied: "Of pi-mono's six hooks, should mini-agent take not a single one?"
"Not right now," Lao Z said. "Your three stated needs are all subscribers, mapping to the three moments onTurnStart, onAfterToolCall, and onTurnEnd — that's enough. convertToLlm and transformContext are transformers; they rewrite the messages sent to the model. mini-agent's trimContext (see context.ts) already does this, but it's a built-in function, not a hook, because it has a clear contract (keep assistant + tool results grouped) and a clear failure (throw when the budget can't fit the smallest unit). The day you need to insert custom context transformation, promote it into a hook. shouldStopAfterTurn is an interceptor; it decides whether the turn stops. mini-agent's stopping logic is: calls.length === 0 means complete, hitting maxTurns means max_turns (see agent.ts) — simple enough to need no hook. Every hook you add is one more debt of 'what if it fails'. Only open a slot at a moment that's proven necessary."
Summary
Lifecycle hooks make each critical moment of an Agent observable and auditable. Before you act, classify by subscriber / interceptor / transformer: the classification decides the failure semantics, and the failure semantics decide whether it lands on the main path or the side path. A subscriber's throw is swallowed and recorded; an interceptor's throw propagates upward — that dividing line is the skeleton of the whole hook system. Which moments need to 'see' and which need to 'decide': think it through before you write the interface; the order can't be reversed.
The interface shape follows from the classification. A subscriber returns no value and takes only minimal context — the less information, the less it tempts intervention in the main path. An interceptor returns a decision result and takes the full ToolCall. A transformer rewrites the input but doesn't decide whether to continue. Narrowing the context guards against future overreach. The trigger order must be written into the contract: onBeforeToolCall has to come after the interceptor's decision and before the tool executes, or the 'about to happen' it records won't match the 'didn't happen' of reality.
Subscribers must not drag down the main path, and this discipline has to land on the signature, the comment, and the test at once. The signature blocks the misuse of 'returning an approval result from the side path'; the comment spells out the dividing line; the test uses a deliberately throwing subscriber to assert the stop reason is unchanged. Synchronous writes to disk are a privilege of the main path; fire-and-forget is the subscriber's norm. If you choose the async-queue approach, you still have to answer 'who flushes on process exit' — that's the host lifecycle's responsibility, not something a hook comment can settle.
Keep this comparison in mind, and look back at mini-agent's current code: BeforeToolCall is the only interceptor seam. When it throws, the current call is recorded as 'tool approval failed', the rest as 'not executed after an earlier failure', and the error is rethrown unchanged (see tool-failure-session.test.ts). That is the template for interceptor failure semantics — a subscriber must never copy it. All four hooks in this chapter are a rehearsal; not one line of src changed. To land it, write the test draft first, and let the requirements prove each moment is needed.
Stage Acceptance
Do not modify src. In examples/mini-agent, write a standalone rehearsal note or test draft:
- List at least 3 moments your Agent needs to observe, and for each write the minimal context it will receive.
- Decide whether each moment needs a subscriber, an interceptor, or a transformer, and explain why.
- For each hook, write one assertion for its failure-semantics test (a subscriber failure does not change the stop reason; an interceptor failure propagates upward).
