← Pi Agents

Seams, Hooks, and Irreversible Decisions

Every hook on the agent core can see something; only some can change it and fewer can refuse it. This chapter maps each hook by what it can still decide, measured rather than assumed.

Chapter 1 ended on a sentence that has been quietly organising the book since:

An extension point is a real architectural seam only for decisions it can intercept before those decisions become irreversible.

It was stated there as a finding about forks of the coding agent, and it was argued from three cases. This chapter tests it on something you can run. The agent core has nine function-shaped options that run around a request or a tool call, and for each one the useful question is not what does it give me but what can I still decide by the time it fires.

The nine

All of these are options on Agent, declared in AgentOptions in the pi-agent-core types. The README documents most of them; prepareNextTurn and prepareNextTurnWithContext are in the declaration and not in the README’s option list. The other callbacks in AgentOptions (streamFn, onPayload, onResponse, onProviderStreamEvent) concern model transport, payloads, responses or event observation rather than the tool-permission decisions mapped below. They still matter: replacing streamFn can change which implementation handles a request, and diagnostics callbacks may see sensitive payload data. This chapter is not a complete audit of every callback in AgentOptions. The first version of the chapter said seven and left out getApiKey and the second prepareNextTurn form, which an independent review caught.

Hook Receives May return
prepareRequest the context, the model, the thinking level a replacement context, model or thinking level. No veto field
transformContext the messages replacement messages
convertToLlm the messages the messages in the provider’s shape
beforeToolCall the assistant message, the tool call, validated args { block, reason, terminate }. No argument-replacement field
afterToolCall the above plus the result replacement content, details, isError, structuredContent, usage, terminate
finishTurn the finished turn and its tool results { action: "end" } or { action: "continue" }
getApiKey the provider name a credential, or nothing
prepareNextTurn only an abort signal, on Agent a new context, messages, model or thinking level
prepareNextTurnWithContext the finished turn (PrepareNextTurnContext) and a signal the same update

The prepareNextTurn row is the one to be careful with. The raw loop’s AgentLoopConfig.prepareNextTurn receives the finished turn. On Agent it does not: if you need the turn, use prepareNextTurnWithContext. The coding agent bridges the two in its compiled source, which is how this was noticed.

Reading that table you would guess they are equally useful. They are not, and the reason is not in the table. It is in when each one runs relative to the thing it is about.

Where they sit

Run one tool-using turn with a recorder on every hook and the order stops being a matter of opinion. This is the observed sequence on 1.0.4:

assert.deepEqual(order, [
	// turn 1: request preparation, then the provider call
	"prepareRequest",
	"transformContext",
	"convertToLlm",
	// the model asked for a tool; execution is announced before the gate runs
	"tool.execute starts",
	"beforeToolCall",
	"afterToolCall",
	"finishTurn",
	"turn_end",
	// turn 2: the same preparation, then the final answer
	"prepareRequest",
	"transformContext",
	"convertToLlm",
	"finishTurn",
	"turn_end",
]);

Two things in that list are not what a reader would guess, and the first draft of this chapter got one of them wrong. prepareRequest runs before transformContext, not after. And tool_execution_start is emitted before beforeToolCall decides. The test’s "tool.execute starts" marker denotes that start notification; it must not be read as evidence that the tool’s execute() body has begun. The event can be emitted for a call that the gate then blocks. The README documents that ordering, and the recorder confirms it. A UI that draws “running…” on the event needs to handle a subsequent blocked/error result without assuming an external effect occurred.

Group the sequence by what has happened by the time each hook fires. The grouping is about timing, and it is what decides where a decision is still cheap:

BEFORE THE REQUEST      prepareRequest, transformContext, convertToLlm
   the conversational provider request has not yet been dispatched

BEFORE THIS TOOL BODY   beforeToolCall
   the model's request and argument preparation already happened;
   this tool's execute() has not started

AFTER A TOOL RESULT    afterToolCall, finishTurn, prepareNextTurn(WithContext)
   a permitted tool may have had external effects; some turns use no tools

The three grouping headings say what has happened, not what a hook is permitted to do. Whether a hook can refuse is a separate question with a separate answer, and the two have to be kept apart: beforeToolCall is the only hook with a declared refusal, and that is because of its declared return type rather than because of where it sits. A prepareRequest that throws stops the request anyway — the heading above is a statement about timing, and throwing is a way of behaving outside the protocol rather than a capability the protocol offers. The sections below give the two separately.

That grouping is the whole chapter. Everything below is the evidence.

Before the request: you can change what the model sees

transformContext and convertToLlm change what is sent. They do not change what is kept. The test sends two user messages, strips one in transformContext, and asks the provider what it received:

sent to the provider:   1 user message
kept in the transcript: 2 user messages

Same for prepareRequest, which can replace the context, the model, or the thinking level for one request. The test swaps a “cheap” model for a “strong” one for a single call and the provider sees the strong one.

The conversational request has not yet been dispatched, so changing its context or model here avoids sending the original request. That does not mean the whole operation is free or effectless: the application, earlier turns and the hooks themselves may already have performed work. Request preparation is a useful place for context policy, such as pruning and model routing, provided the policy also accounts for the resulting prompt and token budget. Chapter 4’s budget arithmetic informs such a transformContext function; it is not itself a built-in hook implementation.

Neither prepareRequest nor transformContext has a declared veto. prepareRequest returns an AgentRequestUpdate — context, model or thinking level — with no block or deny field. Changing the outgoing context is a supported way to change what is sent, not a reliable way to enforce whether a request may be sent at all. If an application must prohibit a request, enforce that decision at a controlled call or transport boundary rather than treating prompt rewriting as authorization.

Be careful about how far to carry that. It is a statement about the return protocol, not about what a JavaScript callback can be made to do. A hook is ordinary code in your process, and a callback that throws is not a documented return value.

Observed on 1.0.4: a prepareRequest that throws Error("blocked request") produces zero provider calls in the scripted test, and the final assistant record carries stopReason: "error". That is useful evidence about this implementation, not a documented authorization API. A change in error handling could change the outcome, so do not use an undocumented thrown exception as the sole barrier protecting a sensitive provider request. Where denial must be guaranteed, make the application check authorization before invoking the agent or enforce it in the controlled transport boundary; retain a regression test for the actual requests sent.

Before the effect: the only point that can refuse

beforeToolCall is the core’s declared pre-execution refusal point for a tool invocation routed through the agent’s tool pipeline. Its result type has three fields, block, reason and terminate. Blocking prevents that invocation’s execute() from running; it does not revoke provider access, roll back previous tools, prevent arbitrary code in other hooks, or intercept a direct call that bypasses the tool pipeline:

const agent = new Agent({
	initialState: { systemPrompt: "x", model, tools: [effectTool(counter)] },
	streamFn: models.streamSimple.bind(models),
	beforeToolCall: async ({ args }) => ((args as any).path === ".env" ? { block: true, reason: "protected path" } : undefined),
});
await agent.prompt("go");
assert.equal(counter.runs, 0);

counter.runs is a side effect inside the tool, so “was it run?” is a number rather than an inference. It is 0. The blocked call came back as an isError tool result carrying the reason, which the model could read and respond to. That is the same fail-safe outcome chapter 16 documented for the coding agent’s tool_call event, because the coding agent is built on this hook. It installs its own beforeToolCall on the core Agent. That is visible in the shipped JavaScript. It is observed, not documented, and you should not build on it as a contract.

One limit that surprises people who have written extensions: the core’s BeforeToolCallResult has no field for rewriting the arguments. It has block, reason and terminate, and that is the whole declared surface. The coding agent’s tool_call event is more permissive — a handler may mutate event.input — but that is the coding agent’s own event, not the core hook, and the chapter is about the core.

Again, the declared protocol is not the same as the limits of the language, and the distinction is worth keeping rather than rounding off. Observed on 1.0.4: a beforeToolCall that mutates args.path produces a tool which receives the mutated path. The compiled loop passes the validated argument object to the hook and subsequently passes that same object to the tool, so in JavaScript a mutation lands. That is especially risky because the mutation happens after schema validation: a rewritten value is not automatically revalidated merely because the original arguments were valid.

So there are three positions, and the honest ordering is by how much you should rely on each:

How to change what a tool receives Status on 1.0.4
Inside the tool, or in prepareArguments on the AgentTool Documented. The supported way
Mutating args inside beforeToolCall Observed. Works, nothing declares it, and the validated-arguments contract is the thing it leans on
A field on BeforeToolCallResult Absent. No such field exists; that part is a statement about the type

Use the first, and validate the value actually used for any security-sensitive effect. The second is worth knowing because it explains why a callback can sometimes do more than its types admit, but it is an implementation detail that can bypass the validated-argument assumption. A permission check must inspect the canonical path, command or target the tool will execute, not merely the model’s original string.

After the effect: you can rewrite the story, not the world

afterToolCall can replace what the model is told. It cannot make the tool not have run:

afterToolCall: async () => ({ content: [{ type: "text", text: "[redacted]" }], isError: true }),
counter.runs              1     the write already happened
tool result the model saw "[redacted]", isError true

This is the distinction the chapter exists for. The write happened. The model was told something else. If the effect was a file written, a message sent or a row deleted, rewriting the final tool result does not reverse it. afterToolCall can redact the final result shown to the model, normalise its format or attach usage. It does not erase data already sent to an external service, logged by the tool, or emitted through an earlier streaming update. It is not a confidentiality boundary or a substitute for preventing an unsafe effect.

finishTurn has the same property one level up. It runs after the assistant message and all its tool results are finalised, and it may return { action: "end" } or { action: "continue" }:

finishTurn returned "end":
   requests made: 1
   tool runs:     1     the tool in the finished turn still ran

It can stop the run from going on. It cannot reach back and stop what the finished turn did. And a "continue" returned unconditionally is an infinite loop, which the README warns about: finishTurn runs again after the next request.

The seam matrix

The grouping from earlier, drawn against what has happened and whether a decision is still available. The diagram shows the ordinary tool-using path, but a provider response need not contain a tool call, a blocked call does not execute its body, and prepareNextTurn is relevant only if another turn is prepared. The matrix below separates declared transformations and refusals from observed implementation details.

    flowchart TD
  PR["prepareRequest"] --> TC["transformContext"] --> CL["convertToLlm"]
  CL --> REQ["provider request"]
  REQ --> DEC{"Tool call?"}
  DEC -->|no| FT["finishTurn"]
  DEC -->|yes| START["tool_execution_start event"]
  START --> BTC{"beforeToolCall blocks?"}
  BTC -->|yes| BLOCKED["failed tool result, no execute()"]
  BTC -->|no| EXEC["execute()"]
  EXEC --> ATC["afterToolCall"]
  ATC --> RESULT["final tool result"]
  BLOCKED --> FT
  RESULT --> FT
  FT --> MORE{"Another turn scheduled?"}
  MORE -->|yes| PNT["prepareNextTurn hooks, as configured"]
  PNT --> PR
  MORE -->|no| END["agent_end"]
  

The diagram is schematic: it omits message events, error/abort paths and queue polling, and it does not assert that every turn has a tool call. In particular, tool_execution_start announces a call before the gate has permitted execution. The undeclared throw and argument-mutation behaviours are discussed separately above; neither should be treated as a supported permission mechanism.

Hook When it fires Can observe Declared transformation Declared refusal or termination Tool effect possible already?
prepareRequest before a provider request yes context, model, thinking level no veto field no for this request
transformContext before conversion yes outgoing messages, not stored history no veto field no for this request
convertToLlm before provider transmission yes provider-facing messages no veto field no for this request
beforeToolCall after validation, before this tool’s execute() yes no argument-replacement field block this tool invocation no for this invocation
afterToolCall after a permitted tool completes yes final tool result no undo or block field yes
finishTurn before turn_end, after the turn finishes yes scheduling action end or continue the run possible
getApiKey when resolving a provider credential yes returned credential no declared request veto no for this request
prepareNextTurn / …WithContext when preparing further work yes context, messages, model, thinking level no declared veto field possible

Read the two qualified cells as absent fields, not as proven impossibilities. The two sections above give the observed behaviour that fills each gap, and the difference between “no field for this” and “this cannot happen” is the difference between a statement about a type declaration and a claim about a running program.

This matrix is mostly not measured cell by cell. The eight tests reported in examples/ch39-seams/ cover the observed order, that a block prevents one tool’s body from running, that a post-execution rewrite does not undo that effect, that a context transform changes a request without changing stored messages, that finishTurn can end or extend a run, that prepareRequest can swap a model, and that runToolCall() routes a nested call through the gate. The remaining claims about absent return fields, including the lack of a request veto and argument-replacement field, come from the 1.0.4 declarations; the getApiKey and prepareNextTurn rows are not established by those eight runtime tests. This is a map of the documented API plus selected observations, not a full behavioral verification of every cell.

Read the refusal column relative to a specific decision. To stop a particular model-issued tool call from entering execute(), use beforeToolCall. To stop additional turns after the present one, finishTurn can return { action: "end" }. agent.abort() is cancellation, whose effectiveness also depends on the work honoring its abort signal. Steering and follow-up queues do not refuse the run: they schedule further input. None of these mechanisms retroactively removes effects from tools that have already run; whether a tool has run at all depends on the point at which intervention occurs.

Hooks before a request shape the model’s outgoing context and provider-facing representation. afterToolCall also shapes what the model will see later, by rewriting a completed tool result; that happens after any tool effect. The core’s declared gate for a model-issued tool invocation is beforeToolCall. Later hooks can affect result presentation and future scheduling, but not undo an already completed external operation.

From decision to layer

Now the rule can be used as a procedure. For any decision you want to control, ask where the last moment of intervention is, and put the control there or earlier.

The decision Latest useful point So it belongs in
Which tools the model may call before dispatching the request that declares them the host-controlled tool list, checked again if tools can change (chapter 42)
May this tool invocation enter execute() beforeToolCall a fail-closed gate over canonical, validated targets
What the model sees in the final tool result afterToolCall a result rewrite, after any external effect
How much context and which model prepareRequest / transformContext request policy
Whether another turn follows finishTurn loop scheduling, after the current turn
Roll back a completed effect no generic core hook tool-level transactions or explicit compensation; OS isolation prevents reach but does not undo a completed action

One field changes that last row slightly and is worth knowing. AgentTool.replay ("never" | "safe") is the tool’s declared “recovery policy for an effect whose durable intent exists but whose outcome is unknown”. It is not an undo. It is a statement the tool makes about itself, and the host’s recovery logic would have to trust it. A value chosen by the party being constrained is the pattern chapter 42 warns about, and here it sits inside Pi’s own tool type.

The last row is the important one. The core supplies no generic transaction or rollback facility for a completed external effect. Some hooks run before a tool body and can prevent its invocation; those that run afterwards cannot reverse what it already did. Recovery requires an effect-aware design — a transaction, idempotent operation or explicit compensation — and prevention of unauthorized effects may require restrictions enforced beneath the process. Isolation limits what can be reached in the first place; it does not rewind actions that were permitted and completed. Chapter 42 builds that argument out of this one.

Nested calls pass through the same gate

A tool that calls another tool should not be a way round your gate. The core exports runToolCall() for exactly this: its declaration says it runs one call “through the same steps as a model-issued call: argument preparation, schema validation, beforeToolCall, execution, and afterToolCall”, and that tools that call other tools use it “so the hooks (for example permission checks) apply to those calls too”. It never rejects for tool failures. A blocked or unknown call comes back with isError: true.

That is the core’s version of what chapter 17 documented for ctx.executeTool(). This one is run: a test in examples/ch39-seams/ passes a call to a protected path through runToolCall() with a beforeToolCall gate, and the tool body’s counter stays at zero; a permitted call runs once; an unknown tool comes back as an error rather than a rejection. If you build a tool that fans out to other tools, call runToolCall() and not the other tool’s execute() directly, or your gate does not see the nested call.

Mapping to the coding agent

If you have written extensions, the correspondence is close enough to be useful and loose enough to be dangerous:

Core hook Extension event you know
beforeToolCall tool_call (but the extension can also mutate event.input)
afterToolCall tool_result
finishTurn turn_end and agent_before_settle returning continue
transformContext context

Proposed, from the shipped source and the documentation: these are related intervention points exposed at two different layers, not interchangeable callback contracts. The coding agent adds events the core does not have (session_before_compact, project_trust, input) because it owns sessions, projects and interface interactions. Some of those events occur before the decision they govern, and others occur after it; the timing must be checked for each event. Do not treat this analogy as a guarantee of event order, argument mutation, or implementation details in the coding agent.

What you can and cannot claim

Documented: the nine hooks and their result types (pi-agent-core README and types.d.ts, 1.0.4), including that beforeToolCall runs after validation and after tool_execution_start, terminate’s batch rule, the infinite-loop warning on finishTurn, and runToolCall()’s contract.

Observed: the hook order, the block-prevents-effect behaviour, the rewrite-does-not-undo behaviour, the transform-sends-not-stores behaviour, and the model swap. All in examples/ch39-seams/, on 1.0.4, under scripted responses. That the coding agent installs beforeToolCall and finishTurn on the core is observed in its compiled JavaScript.

Proposed: the before-request, before-effect, after-effect grouping, the decision table, and the advice to put prevention in a tool or beneath the process.

Next

You now know where you can stop a single step. What you have not done is decide how many steps there should be, or which of them should be a model at all. The pieces from chapters 36 to 38 (a call, a loop, a typed step) can be combined in ways that cost very different amounts and fail in very different ways.

Chapter 40 composes them.