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.