← 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) observe or replace the transport and are not decision points in this sense. The first version of this 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 event says a tool is starting even for a call the gate is about to block. The README says the hook runs after tool_execution_start and argument validation. The recorder confirms it. A UI that draws “running…” on that event will draw it for refused calls.

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
   nothing has been asked of the model yet

BEFORE THE EFFECT       beforeToolCall
   the model has decided; nothing has run

AFTER THE EFFECT        afterToolCall, finishTurn, prepareNextTurn(WithContext)
   the tool has run; its consequences exist

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.

Nothing can be undone here because nothing has happened. A request that never went out costs nothing and has no effect. This is the cheapest place to make a decision, and it is where context policy belongs: pruning, summarising, routing to a model by the size of the input. Chapter 4’s budget arithmetic is a transformContext function.

Neither hook has a declared veto. PrepareRequest returns an AgentRequestUpdate — context, model, thinking level — and there is no field in it that means do not send this. So a hook that does not want a request to go out has no documented way to say so, and the supported way to express that decision is to change what goes out.

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, and the final assistant record carries stopReason: "error". That works, and it is a defensible way to fail closed on a request you did not expect. It is also not in the type, so it is observed, not documented: a future release could reasonably give the hook a documented refusal, and an implementation that today converts a throw into a failed turn might stop doing so. For a policy you need to rely on, prefer the documented surface; for a guard whose failure mode you would rather be a visible error than a silent send, the throw is a reasonable choice precisely because it is loud.

Before the effect: the only point that can refuse

beforeToolCall is where a decision is still reversible, and it is the only hook in the table that can say no. Its result type has three fields, block, reason and terminate:

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.

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. The second is worth knowing because it explains why an extension can sometimes do more than its types admit, and because a reader who hits it should know it is leaning on an implementation detail rather than following a contract.

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, no later hook can take it back. afterToolCall is right for redacting a secret from output, normalising a format, or attaching usage. It is wrong for anything you meant as a prevention.

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. Note the fourth column: it is the declared return protocol, and the dotted arrows are the places where the language permits more than the type does.

    flowchart TD
  subgraph BEFORE["nothing has happened yet"]
    PR["prepareRequest"] --> TC["transformContext"] --> CL["convertToLlm"]
  end
  CL --> REQ["provider request"]
  REQ --> BTC["beforeToolCall"]
  BTC --> EXEC["execute()"]
  EXEC --> ATC["afterToolCall"]
  ATC --> FT["finishTurn"]
  FT --> PNT["prepareNextTurn(WithContext)"]
  BTC -. "declared: block, reason, terminate" .-> D1["the only declared refusal"]
  PR -. "no veto field; a throw stops the request" .-> D2["observed, not declared"]
  BTC -. "no args field; mutating args.path lands" .-> D3["observed, not declared"]
  

Solid arrows are the declared path and the declared right to decide. Dotted arrows are the observed-but-undeclared paths, which is where this chapter’s careful qualification lives: they work on 1.0.4, they are real, and they are not something to build a policy on.

Hook When it fires Can observe Can transform Can refuse Effect already happened
prepareRequest before each provider request yes context, model, thinking level no veto field no
transformContext before conversion, each request yes messages (sent, not stored) no no
convertToLlm each request yes the wire shape no no
beforeToolCall after the call is validated, before execute() yes no args field yes no
afterToolCall after execute(), before the result is recorded yes the result no yes
finishTurn after the turn’s tools, before turn_end yes no end or continue yes
getApiKey before a request needs a credential yes the credential the key only: it cannot refuse the request no
prepareNextTurn / …WithContext after turn_end, when the loop continues yes context, messages, model no yes

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 in examples/ch39-seams/ measure the order, that a block prevents the effect, that a rewrite after the effect does not undo it, that a transform is sent but not stored, that finishTurn can end or extend a run, that prepareRequest can swap the model, and that runToolCall() passes a nested call through the gate. The remaining cells, including every “no” under can refuse and can transform for afterToolCall and finishTurn, the getApiKey and prepareNextTurn rows, and that beforeToolCall has no argument-rewrite field, are readings of the declared result types, not runs. An independent review found the earlier wording (“every other cell has a test”) overstated this.

Read down the “can refuse” column with one correction: refuse is relative to a named decision. For the decision “may this effect happen”, there is exactly one yes, beforeToolCall. For the decision “does this run continue”, finishTurn returning { action: "end" } is also a refusal, and so are agent.abort(), and the steering and follow-up queues, which can keep a run from ending. A seam is a seam for a decision, and the principle in this chapter should be read that way. What does not change is the effect column: every hook that can refuse the run does so after the tools’ effects exist.

All the hooks that can shape the model’s view sit before the request. The one hook that can stop an action sits immediately before it. Everything after is bookkeeping about something already done.

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 point it can be stopped So it belongs in
Which tools can exist at all when you build the Agent the tool list: the strongest control, chapter 42’s rung 0
May this call run beforeToolCall a gate that reads a table you own
What the model sees from a call afterToolCall a result rewrite, with the effect already done
How much context, which model prepareRequest / transformContext request policy
Whether another turn happens finishTurn loop policy, after the turn’s effects
Undo something a tool did not in the core the tool itself (make it transactional) or an operating-system boundary

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. No hook in the agent core can undo an effect, because by the time any of them fire the effect is the tool’s own business. A control that must hold even if the model is wrong, the gate is wrong and the tool is wrong has to live where the effect lives: in the tool’s design, or beneath the process. 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 the same seams seen from two layers. The coding agent adds events the core does not have (session_before_compact, project_trust, input) because it owns sessions and projects, and those are exactly the decisions chapter 1 said an extension sees too late. Do not read the table as a guarantee of how the coding agent is implemented.

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.