Hook into the loop
You’ve already used one lifecycle hook without dwelling on it - the session_start handler in the very first extension skeleton, back in the anatomy lesson, just posted a notification. That was the smallest possible instance of the mechanism this whole chapter turns out to rest on: pi.on(eventName, async (event, ctx) => { ... }). Everything you’ll build in the rest of this course that reacts to what Pi is doing rather than waiting to be invoked - a permission gate, an enforced task list, a status line - is this same call, wired to a different moment.
The events, in one place
Section titled “The events, in one place”Pi’s extension API exposes a broad lifecycle surface. These are the events this course builds on:
| Event | Fires | Notes |
|---|---|---|
session_start | Session loads | event.reason is "startup", "reload", "new", "resume", or "fork" |
session_shutdown | Session ending | Clean up anything you opened during the session |
before_agent_start | Before an agent turn starts | Can inject messages or rewrite the system prompt for that turn |
tool_call | Before a tool executes | event.input is mutable - return { block: true, reason } to stop it |
tool_result | After a tool executes | Can modify or replace the result before the model sees it |
input | User input arrives, before skill/template expansion | Return { action: "handled" } to swallow it, "continue" to pass it through |
context | Before an LLM call | Modify the message list without touching what’s stored |
message_end | End of a message’s lifecycle | Can replace the message if the role stays the same |
Two more you’ll reach for later in the course: tool_execution_start and tool_execution_end, which fire around a tool’s actual execution rather than before it - tool_execution_end is how a status line tallies the calls that really completed.
In the order they fire across a session, with the thing that matters most about each - whether you can change what happens next:
session_start read-only session loaded; note the reason, set things up │ ▼ before_agent_start mutable inject messages or rewrite this turn's system prompt │ ▼ tool_call mutable edit event.input in place, or return { block: true } │ ▼ tool_result mutable modify or replace the result before the model sees it │ ▼ tool_execution_end read-only tally the calls that really completed (status line) │ ▼ session_shutdown read-only clean up anything you opened during the sessionThe mutable ones are where your extensions get teeth - they’re the points where returning a value actually changes what Pi does next.
Mutating and blocking: tool_call
Section titled “Mutating and blocking: tool_call”This is the event with teeth. event.input is the actual arguments the tool is about to run with, and you can edit them in place before Pi executes the call - or refuse the call outright:
pi.on("tool_call", async (event, ctx) => { if (event.toolName === "bash" && event.input.command.includes("stash.db")) { pi.appendEntry("stash-db-touch-log", { command: event.input.command, at: new Date().toISOString(), });
if (/\brm\b|DROP TABLE/i.test(event.input.command)) { return { block: true, reason: "Refusing a destructive command against stash.db directly - export a migration instead." }; } }});Two things worth noticing here, because they’re easy to gloss over: later handlers see whatever earlier handlers already mutated, and Pi doesn’t re-validate the input after a mutation runs - so if you have several extensions loaded that both touch tool_call, the order they load in matters. And pi.appendEntry writes structured data straight into the session’s own append-only log, which is why the audit trail above survives a restart or a /fork without you standing up a separate database for it - you’re piggybacking on the session file Pi already keeps.
This is a taste of a much bigger idea, deliberately kept small here. A regex check against a bash string is not the guardrail stash’s real database deserves - that’s a whole extension of its own, coming next chapter.
Shaping the turn before it starts: before_agent_start
Section titled “Shaping the turn before it starts: before_agent_start”The other hook worth seeing now, because it’s the mechanism behind Pi’s own system-prompt-override files:
pi.on("before_agent_start", async (event, ctx) => { if (event.systemPrompt.includes("readability")) { return { systemPrompt: event.systemPrompt + "\nWhen re-extracting a URL, always try readability mode before raw mode.", }; }});Returning { systemPrompt: ... } replaces what the model sees for that turn - append to it, as above, or template it entirely. It’s a lighter-weight version of what .pi/SYSTEM.md does at the whole-session level (chapter 3), applied per-turn instead.
Where this leaves the “guarantee” question
Section titled “Where this leaves the “guarantee” question”Stack up what you now have against stash’s database: a convention in .pi/APPEND_SYSTEM.md telling the model to be careful with stash.db (a rule - it lives in context, the model weighs it, and it can get compacted out of a long session); a tool_call hook that can inspect and refuse a specific dangerous command (what you just wrote - genuinely a hook, deterministic code that runs whether or not the model remembers anything). Notice there’s no third, separate “permission” primitive here the way some other tools ship one - Pi doesn’t have a built-in allow/deny layer distinct from tool_call. When you build one next chapter (a proper rule list plus ctx.abort()), it’ll be built from this same hook - just aimed at a whole class of actions instead of one regex.
Stress-test that against a clock, because what a rule buys you while you’re watching isn’t what it buys you at 2am with nobody in the chair:
MCP is not core here
Section titled “MCP is not core here”If you’ve used MCP servers with another agent, the instinct at this point is to reach for one - bridge to some external system, get a pile of tools for free. Worth being precise about where Pi actually stands on that. MCP integration exists - there’s community support for connecting Pi to MCP servers - but it isn’t the core, encouraged path the way registerTool is, and Pi’s creator has written that MCP is overkill for a lot of what people reach for it to do, preferring small CLI tools (or, in-process, an extension like the one you just built) over standing up a whole protocol server. Treat that preference as the author’s stated philosophy rather than a hard rule, but the practical reason to take it seriously is concrete either way: every tool mounted into a session - MCP or not - spends context. Its schema, its description, its usage guidance all sit in the system prompt on every single turn, whether or not you end up calling it. reextract_url cost you a few hundred tokens of always-on schema to solve a problem that lives entirely inside stash’s own repo. Reaching for an MCP server would have cost more of that budget to solve a problem an in-process tool already solves.
The distinction that actually matters: MCP is for reach - a real bridge to a system you can’t otherwise touch, a third-party API, someone else’s database. If the capability you need lives inside the repo you’re already sitting in, a registerTool extension is the cheaper, more direct answer, and it’s the one this whole chapter has been showing you how to build.
What you’ve built
Section titled “What you’ve built”Two moves in your stash harness that weren’t there this morning - a command you trigger, a tool the model triggers - and underneath both, the same pi.on mechanism that every later extension in this course reuses for a different job. The next one is the guardrail that regex check above was a sketch of: a real, understood reason to add back the permission layer Pi ships without, on purpose. Next: rebuild the defaults.