Skip to content

Appendix 08

Extension Mechanism Addendum: The Interface Boundaries of Hooks ​

Xia A and Lao Z watch the main path pass through a decision gate and transformation roller while a side channel collects events.

Xiao A had learned about Hooks in Part I · Concepts. But when he actually went to hook one into an Agent, a pile of names tripped him up first: convertToLlm, transformContext, beforeToolCall, afterToolCall, finishTurn, prepareNextTurn, prepareRequest… Which ones can intercept, and which ones only watch? Which run before a request, and which run after? The names alone don't tell you.

"Don't guess from the names." Lao Z said. "The names will mislead you. beforeToolCall sounds like 'before the tool call', but it actually lands 'after the arguments are validated'. What you intercept is already a valid call, not the raw input. This appendix lists the low-level loop hooks that are really verifiable in this pi snapshot; they are not a general Hook standard. Switch to another Agent framework, and the interfaces and timing may be completely different."

"Then is there a method to reading these interfaces?" Xiao A asked.

"Record each hook by three things: when it fires, what it can change, and what happens when it fails." Lao Z said. "The first two decide what you can do with it. The third decides whether you have to write it with your heart in your mouth. The name beforeToolCall only answers 'when'. The two you really need to pin down are the last two."

Xiao A: "Why cover only pi's low-level loop hooks, and not the broader Hook picture from Part I · Concepts?"

"Part I · Concepts speaks to intent; this gives you coordinates." Lao Z said. "The interceptor-subscriber-transformer classification helps you build a mental model. But when you actually hook something in, you need the concrete location and timing of each interface in this pi snapshot. The classification tells you 'what is possible'; the coordinates tell you 'where it can land in this framework'. Miss either one, and the hook you write is either hung in the wrong place or attached to an interface that doesn't exist."

"There's one misconception to clear out first." Lao Z continued. "Don't treat 'the hook exists' as 'the permission exists'. In the table, the convertToLlm row says 'not responsible for permissions'. That isn't a disclaimer; it's the real boundary of the interface: it only handles transforming the message shape, and permission checking happens elsewhere. When you read the table, read the 'Outcome / Boundary' column along with it. What a hook can do and what it cannot do matter just as much."

Tell the Three Kinds of Hook Apart First ​

Lao Z had Xiao A sort this pile of names into three kinds by "what they can do":

  • Interceptor: can allow, reject, or rewrite the main path. beforeToolCall (can block) and afterToolCall (can override the result) belong to this kind.
  • Subscriber: only observes events and does not rewrite the main path through its return value; whether exceptions affect that path depends on the implementation. UI rendering and logging usually use subscribers.
  • Transformer: rewrites the input without returning a continue-or-stop decision; throwing can still prevent a request. convertToLlm and transformContext belong to this kind.

"This classification describes purpose; failure consequences still depend on the calling code." Lao Z said. "An interceptor failing can make the whole turn fail. pi's agent.ts awaits each listener without isolating individual listener exceptions, so a subscriber throwing can also terminate the main path. If transformContext or convertToLlm throws, the current provider request is prevented. Bad context may be sent only if a transformer successfully returns incorrect content and nothing downstream rejects it. When debugging, use both the classification and the actual exception propagation path to assess the impact."

Xiao A: "Then when I write one, which kind do I write it as?"

"Choose by whether you want to affect the main path." Lao Z said. "If you only want to record that 'some tool was called', write a subscriber: its failure shouldn't crash the turn, so wrap it in try/catch and make it tolerant. If you want to block a dangerous call, write an interceptor: its failure semantics must be explicit — fail-open or fail-closed, no ambiguity. If you want to rewrite the context, it's a transformer: distinguish throwing from returning incorrect content. The former prevents the current request; the latter may send bad data, so validate the returned content."

Xiao A jumped in to summarize: "So read-only is a subscriber, changing the input is a transformer, and changing the decision — allow, reject, continue, stop — is an interceptor?"

"Right. Ask yourself three questions in order: Do I want to change the output? Do I want to change the input? Am I just watching? The answers sort you into one kind. Base defensive code on exception propagation and failure consequences, rather than ranking it by category. Before you start typing, ask one more: if the hook I'm about to hang here fails, what should the whole loop do? Only start writing once you can answer that."

Xiao A asked again: "Is the debugging difficulty very different across the three kinds of hook too?"

"It can differ, but the category alone does not rank the difficulty." Lao Z said. "A subscriber that catches and records its own exceptions can be diagnosed through logs; without isolation, it can interrupt the turn. A transformer throwing can be detected before the request, while incorrect content returned successfully may only surface when the model answers wrongly. For an interceptor, check explicit rejection and exceptions separately too. The farther the debug point is from the trigger point, the harder it is to trace. Record the trigger location, outcome, and failure reason for every kind."

"That 'ask one question' — can you give a concrete example?"

"Take an interceptor." Lao Z said. "You hang a beforeToolCall to block the rm tool. First you have to answer: when this hook itself throws, does the tool still run? If it runs, you blocked nothing. If it doesn't, does that exception count as the 'successful block' you wanted? Both answers are valid, but you must pick one explicitly and write it down. An ambiguous hook is the most dangerous: its behavior depends entirely on the loop's mood at the moment."

Verifiable Loop Hooks ​

The table below lists the low-level loop hooks that really exist in the pi v1.0.0 snapshot. The locations and trigger timings come from packages/agent/src/types.ts and agent-loop.ts, not from a documentation promise.

InterfaceLocation and TriggerOutcome / Boundary
convertToLlmAgentConfig; before every provider requestTurns AgentMessage[] into LLM Message[]; not responsible for permissions
transformContextAgentConfig; before convertToLlmAdjusts the context; errors affect the request path
beforeToolCallarguments already validated, after tool_execution_start, before executionCan return { block, reason }; an abort signal needs the hook to respond to it itself
afterToolCalltool finished, before the final tool result eventCan override content, details, structuredContent, isError, usage, or terminate
finishTurnassistant and all tool results already appended, before turn_end; also called on error/abortedReturning { action: "end" } ends immediately; { action: "continue" } guarantees one more request; on error/aborted the decision is ignored; does not cancel tools already running
prepareNextTurnbefore the next turn_start; not called on the first turnReplaces the context, model, or thinking level, or appends messages; not called once finishTurn has decided to end
prepareRequestbefore every provider request; also called on the first turn, after any messages pending injection are appendedReplaces the context, model, or thinking level; does not read the queue, does not append messages

Xiao A stared at the table and asked: "finishTurn and prepareNextTurn — which comes first? Could the preparation finish and then everything stops, so the prep work was for nothing?"

"v1.0.0 adjusted this order on purpose." Lao Z said. "finishTurn decides whether to continue when the current turn wraps up; prepareNextTurn is deferred until the next turn actually starts. Once the decision is to end, the next turn never gets prepared. In the old version, shouldStopAfterTurn came after prepareNextTurn, so the side effects of preparation happened first — that pit is gone in the new version. The README of the agent package spells out the migration: wherever you used to return true, now return { action: "end" }."

"There are two more details that are easy to miss." Lao Z continued. "First, finishTurn is also called for error and aborted responses, only its decision is ignored. The README warns that when you migrate from an old hook, if your decision logic has side effects, or assumes the response was a success, you have to rule out those two cases yourself first. Second, unconditionally returning { action: "continue" } causes an infinite loop, because after the next request ends it gets called again."

"So if I hang a finishTurn that returns end, the tools that are running get aborted too, right?"

"Quite the opposite." Lao Z said. "When finishTurn is called, this turn's tools have all finished and their results are appended. It only decides 'should I send the next request', and it cancels nothing that has already started. Many people step in this pit: they think stopping equals aborting. It's a stop point, not an interrupt switch. To really abort a running tool, you go through a signal, not this hook."

Xiao A: "convertToLlm and transformContext both change the context. Where's the difference?"

"transformContext changes the raw material fed into the conversion; convertToLlm changes the final shape after conversion." Lao Z said. "The former runs 'before the conversion', where you can trim, add to, or reorder the context. The latter runs 'at the moment of conversion', turning AgentMessage[] into the Message[] the provider understands. To unify the message format at the last step, hang convertToLlm; to first decide 'which material enters the context', hang transformContext. Either throwing prevents the current provider request. If they successfully return incorrect content, the former affects the conversion input and the latter affects the converted request content."

"One more detail about the order," Lao Z added. "transformContext runs before convertToLlm, so the changes from transformContext pass through convertToLlm. If both your hooks touch the same message, the latter's changes land on top of the already-changed material. Draw this chain out before you write the hooks — it's far cheaper than guessing the order from the event logs afterward."

Xiao A thought of another spot: "Then prepareNextTurn and transformContext both 'prepare for the next turn'. Do they overlap in function?"

"They overlap, but they act at different times." Lao Z said. "transformContext runs before every turn's request, and changes only the material fed into convertToLlm this one time. prepareNextTurn runs between turns, and prepareRequest runs before every request; the context, model, and thinking level they return replace the running state, affecting this request and the ones after. Get the scope wrong, and you can't tell which change took effect."

The Two Most Misused Interfaces ​

beforeToolCall: Blocking Once ≠ a Security Control ​

Source-code fact: BeforeToolCallResult in packages/agent/src/types.ts:

ts
export interface BeforeToolCallResult {
	block?: boolean;
	reason?: string;
	/** When blocked, hints to stop after this batch of tools; only takes effect if every result in the batch carries terminate */
	terminate?: boolean;
}

Xiao A: "If beforeToolCall blocks a tool once, is the security control done?"

"No. It only takes effect on this loop path. Three boundaries to keep clear:"

  • When the hook isn't installed: it fails open. Don't assume 'I didn't write beforeToolCall, so the tool won't run'.
  • When the hook fails: the exception follows the loop's error handling, which may not be a 'safe failure'.
  • Outside the hook: the tool implementation still has to validate its own input and limit its own permissions. beforeToolCall is a gate at the loop layer, not a permission at the tool layer.

"The tool's input validation (workspacePath, argument schema) and beforeToolCall are two independent gates." Lao Z stressed. "Remove either one and the other still stands; but don't tear one down because the other is there."

Xiao A: "How do you define this gate's failure semantics so they count as explicit?"

"Pick one of three, and write it clearly." Lao Z said. "Fail-open, fail-closed, or crash-on-failure — all three are explicit semantics. The one you can never accept is 'haven't thought it through', because an un-thought default is usually fail-open. Which one you pick depends on the scenario: an audit-style block can fail open and record; a high-risk block should reject; a block that 'must stop it or something terrible happens' should crash on failure — better to do no work than to let it through. Defining failure semantics is answering the question 'would I rather block by mistake, or pass by mistake?'."

Xiao A pressed: "Then what about the reason field? After a block, where does that string go?"

"Back to the model, as context." Lao Z said. "Next turn the model sees 'this call was rejected, because…'. So reason isn't a note for humans to read — it's behavioral guidance for the model. Write 'argument out of bounds' versus write 'the file path cannot contain ..', and the model acts completely differently next turn. The more specific the reason, the more effective the model's self-correction. Write it too vaguely, and next turn it runs into the same wall in the same pose. This lesson applies to tool error messages too: the granularity of the failure reason directly decides whether the model can fix it."

"One last action, outside the hook." Lao Z said. "beforeToolCall can only block; it can't handle things for you. After a block — record it, notify, retry with changed arguments, or just stop the loop — those follow-up actions must be written by the host code. The hook is the gate; which road to take after the gate closes is the host's business. Only by keeping 'the block' and 'the handling after the block' separate do you avoid the awkwardness of 'it got blocked, but nobody deals with it'."

afterToolCall: Override Is Not Deep Merge ​

Source-code fact: AfterToolCallResult in the same file is a partial-override result, carrying content, details, structuredContent, isError, usage, and terminate. A field you provide replaces the original value; it is not a deep merge into content. There's one exception: when you replace only content without giving structuredContent, the loop drops the original structuredContent, and the comment explains this is to avoid the two being out of sync.

Xiao A: "What if I only want to change one field inside content?"

"You have to return the complete content; don't expect the framework to merge it for you." Lao Z said. "That's the semantics of a partial override: a field you give is replaced wholesale, a field you don't give keeps its original value. Give only a fragment of content, and that fragment is all the model sees — not 'the original content plus your change'. These semantics are completely different from a deep merge. Confuse them and you get silent data loss."

Lao Z added a reminder about failure semantics: "When afterToolCall overrides isError, you've taken over the judgment of 'does this call count as a failure'. Override a result that should be an error into a success (say, to 'patch it so it keeps running'), and the model will use the wrong result as if it were normal data. This kind of apparently kind rewrite is often the source of a chain of garbled answers afterward. Before you override, ask first: am I changing the display, or changing the meaning? An override that changes meaning calls for the utmost care."

"Then the terminate field." Lao Z said. "It's a hint from the tool layer to end the loop early; beforeToolCall can carry it on a block too. Note what the comment says: it stops only when every tool result in the batch carries terminate — one of them carrying it doesn't count. Keep terminate and finishTurn apart: the former is voted on by tool results, the latter is decided directly by the host when the turn wraps up. If you want 'stop once this batch of tools finishes' and every tool agrees, use the former; if you want the host to decide by its own rules, use the latter. Mix them up and you get 'wanted to stop but ran one extra turn'."

Xiao A: "What about the usage override? That can be changed too?"

"It can, but think through why before you change it." Lao Z said. "usage is the token accounting reported to the upper layer. Normally you don't need to touch it; you only need to when 'the usage carried inside the tool result must be merged into this call'. The risk of overriding usage is audit distortion — the upper layer computes cost and does monitoring from usage, so changing it is tampering with the cost books. Unless you have a clear merge need, don't touch this field."

Xiao A wrote two words beside usage: change with care.

A Goal in the Docs ≠ Runtime Code ​

Lao Z finally warned about a trap.

"The old version of packages/agent/docs/agent-harness.md had a section on a general harness hook plan marked 'designed, not implemented'. In v1.0.0 it was deleted along with the harness." Lao Z said. "That makes one point exactly: don't mistake a design goal in the docs for current runtime code. The docs write 'we intend to support these hooks'; the source writes 'these hooks really exist right now'. This appendix lists only the interfaces verifiable in the AgentConfig / loop contract — the ones that really exist in the source."

Xiao A: "Then can I trust the descriptions in the README?"

"Source-code fact comes first." Lao Z said. "For example, packages/agent/README.md says finishTurn also runs on error and aborted, but its decision is ignored — verify that against the runLoop code. When the README and the source conflict, take the source as authoritative and mark the README for update. Don't reconcile the conflict; pick one and state the reason."

"Here's a rule of thumb to judge by: between 'designed' and 'implemented' lies one grep." Lao Z said. "An interface whose symbol name you can't find in the source — however completely the docs describe it — treat it as a plan, not as a usable hook. The reverse: an interface that exists in the source but the docs don't mention is a docs debt, and it doesn't stop you from using it. The source is the fact layer; the docs are the intent layer. Weigh the intent layer against the fact layer."

Xiao A: "Then after a version upgrade, will these hook interfaces change?"

"They will, and it won't tell you when they do." Lao Z said. "pi keeps evolving; the interfaces, the timing, and the failure semantics can all shift. Every location in this appendix is based on the current snapshot — re-verify after an upgrade, and don't use old coordinates as a new map. The way to re-verify is those three questions: is the interface still there? did the trigger timing change? did the failure semantics change? Only once all three are answered do you dare keep using it."

The Value of Hooks and Their Hard Limits ​

Xiao A: "So what can a Hook actually guarantee?"

"The value of a hook is that it lets observable, auditable extension points into the control flow." Lao Z said. "You can record before a tool runs, transform before a request goes out, and decide whether to stop after a turn ends. But hard security constraints must still be carried by the tools, the permissions, and the execution environment — not by a hook alone."

Xiao A: "If a hook can't be the last line of defense, then how is it different from 'configuration'? Aren't both just plugging things into the Agent?"

"The difference is whether it enters the control flow." Lao Z said. "Configuration is static: you read it, and it doesn't act. A hook is live: it gets called at a specific moment, can return a result, and that result changes the main path. Configuration changes 'what the Agent is like'; a hook changes 'what the Agent does at a given instant'. So a hook's interface and timing must be verified exactly, while configuration only needs its values checked. This also explains why this appendix spends so much space on timing: get a config wrong, fix one line; hang a hook at the wrong moment, and the behavior goes entirely off."

Xiao A asked again: "How exactly does the 'observable' value of a hook pay off?"

"It turns an opaque loop into a marked loop." Lao Z said. "Without a hook, tool execution is a black box — you only know it happened, not how. With a hook, you leave a mark at every key point: before a request goes out, before a tool runs, after it runs, when a turn ends. With enough marks, the loop's behavior can be replayed, audited, and analyzed — that's the foundation debugging and incident post-mortems need."

Xiao A: "For that auditing use, is there a recommended way to land it?"

"There is, and the subscriber is the kind best suited to auditing." Lao Z said. "Subscribe to events before and after tool execution, and write the argument summary, call ID, and result into the audit log. Read-only does not mean exceptions are automatically isolated: pi waits for listeners, so an audit subscriber must catch and handle its own exceptions to prevent a recording failure from interrupting the main path. Its execution time also adds to the wait. Don't mix an audit hook and a blocking hook: a block failing can fail the whole turn, and you don't want legitimate tools to stop running because 'the audit component broke'. Auditing uses subscribers, blocking uses interceptors — each in its place."

"One more discipline." Lao Z said. "The audit log should contain the call ID and an argument summary, but not the full secret payload. Logs go into files, into backups, into incident post-mortems. Writing a secret key into a log is moving the audit subject into the incident scene. Auditing needs to answer 'who called what, when', not 'what is the full text of the arguments'."

Xiao A closed his notebook and asked one closing question: "So what should I safely hand over to a hook, in the end?"

"Hand it observation and orchestration; don't hand it the last line of defense." Lao Z said. "Observation — recording, auditing, statistics — the subscriber can handle. Orchestration — inserting policy at the right moment, preparing the context between turns — the transformer and the interceptor can do. Last line of defense — whether the hook is installed or not, installed right or wrong, the main path must still be safe; that's the tool layer's and the permission layer's business. Only by telling these apart will you not discover, in an incident post-mortem, that 'that security hook was never installed, and we thought it was'."

Xiao A thought a moment and added: "So I should design the tools to the standard 'safe even without the hook', then hang the hooks to the standard 'more controllable once installed'."

"Right, that's the order." Lao Z said. "First make sure the main path is safe, then talk about extension; first make sure the default behavior is correct, then talk about blocking. A hook is a node on the main path; the main path itself is the foundation."

The Two Boundaries of Hooks

  1. A soft extension point, not a hard security layer: a hook can block and can rewrite, but its presence is not enforced (not installed means fail-open), and its failure does not guarantee safety.
  2. Security needs defense in depth: the tool layer validates input, the permission layer limits scope, the execution environment (OS sandbox/container) isolates side effects, and the hook layer does auditing and policy. Each of the four layers has its own duty; none can replace another.

Back to the book's overall goal: a hook makes the Agent's control flow extensible and observable; but security, permissions, and isolation are independent engineering problems, and you can't expect one hook interface to solve them.

Xiao A folded the table and put it in his pocket. The next day, the first hook he hung on the Agent was a subscriber for auditing. He remembered Lao Z's line "tell the three kinds apart first", and the line "auditing uses subscribers, blocking uses interceptors". The names were still the same names — but this time he could tell them apart.