Skip to content

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.

Pi’s extension API exposes a broad lifecycle surface. These are the events this course builds on:

EventFiresNotes
session_startSession loadsevent.reason is "startup", "reload", "new", "resume", or "fork"
session_shutdownSession endingClean up anything you opened during the session
before_agent_startBefore an agent turn startsCan inject messages or rewrite the system prompt for that turn
tool_callBefore a tool executesevent.input is mutable - return { block: true, reason } to stop it
tool_resultAfter a tool executesCan modify or replace the result before the model sees it
inputUser input arrives, before skill/template expansionReturn { action: "handled" } to swallow it, "continue" to pass it through
contextBefore an LLM callModify the message list without touching what’s stored
message_endEnd of a message’s lifecycleCan 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 session

The mutable ones are where your extensions get teeth - they’re the points where returning a value actually changes what Pi does next.

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:

Four constraints you want to hold, three homes each: a rule (the model is told), a permission (the harness forbids a class of action), a hook (your code runs on the rail, every time). All four start in the rules file. Re-home them - then move the clock, because what holds while you watch isn’t what holds at 2am.

situation
  • the fragile modulea preference · low stakes

    The auth module is fragile - prefer minimal, surgical diffs there.

    held - you’re the gate

    “Minimal and surgical” is a judgment call, not a checkable condition. You want the model informed and weighing it - and your diff review is the backstop for the times it doesn’t.

  • the secrets walla never · high stakes

    The agent must never read secrets/.

    held - you’re the gate

    An instruction the model weighs - so “never” actually means “unless a debugging trail leads there after the line compacted out.” Walls that matter don’t get to be suggestions.

  • the tested-commit gatea condition on content · high stakes

    No commit that touches money code unless the money tests pass.

    held - you’re the gate

    It works all morning, which is what makes it dangerous. The line compacts out at hour three, and the untested commit lands at 2am with nobody to catch it.

  • the every-time formatteran every-time · low stakes

    Every file the agent writes gets formatted - every time, not most times.

    held - you’re the gate

    The model formats when it remembers, and “every time” done by memory is “most times.” No single miss hurts; the drift and the diff noise pile up.

holding4 of 4shaky0silently broken0

All four look fine - and that’s the trap state. While you watch, an instruction is indistinguishable from a guarantee, because you’re the enforcement. The file didn’t hold the line; you did. Move the clock.

Each tool wears its own names - permissions may be an approvals-and-sandbox dial, a hook a plugin on lifecycle events - but the ladder is the same: guarantee strength is set by what sits in the loop - the model’s memory, a harness wall, or your code.

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.

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.