Skip to content

Course · Pi · Your first extension

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 for 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, a subagent handoff - is this same call, wired to a different moment. Here’s the falsifiable version of that claim: count the table below before you keep reading. That count is the entire event vocabulary the rest of this course reuses - not a subset, not a starting point, the whole thing.

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

Eight. That’s the number - the complete event vocabulary this course draws from, and every gate, tracker, and handoff you build from here on is one of these eight, aimed at a different moment. Two more exist and you’ll meet them later, but they don’t add to that count: tool_execution_start and tool_execution_end fire around a tool’s actual execution rather than before it, for tallying calls that already ran - tool_execution_end is how a status line counts the calls that really completed, not a ninth or tenth thing to decide between.

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. Here’s the hook that finally puts a real guardrail on stash.db, and the claim to watch it prove: a bash command that runs DROP TABLE against that file should get blocked, full stop, with no chance for the model to argue its way past it. Watch it get blocked below.

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." };
}
}
});

Reload, ask the model to run bash with DROP TABLE entries; against stash.db, and check the prediction: the call never reaches the database. event.input.command matches the regex, the hook returns { block: true, reason: ... } before pi.exec or anything like it ever runs, and the model sees a refusal instead of a result - the same way it would see any other tool error.

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.

event.input mutable, a { block: true } return that stops the call cold - that’s the whole vocabulary a guardrail needs.

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. Say stash grows a real reason to want that: you decide the weekly digest of unread saves should post to a Slack channel instead of sitting in the database. That’s a real external system with its own auth, its own API someone else designed, its own failure modes - exactly the shape of job MCP exists for. Re-extracting a URL was never that. The URL, the database, and the pipeline all live in the same repo you’re already sitting in, which is why registerTool was the right call and an MCP server would have been the wrong one.

Worth being precise about where Pi actually stands on this, beyond the Slack case. 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. You don’t have to take reextract_url’s cost on faith - you already have the instrument for measuring it, from module 2. Reload once with stash-tools.ts moved out of .pi/extensions/, note the footer’s ctx percentage (or call ctx.getSystemPrompt() and count it yourself); reload again with the file back, and read the difference. Whatever that delta is, it’s reextract_url’s exact, permanent rent, paid on every turn whether you call the tool or not. Reaching for an MCP server for the same job would have added more schema than that, not less, to solve a problem the in-process tool already solves for free.

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, the Slack channel above. 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. You’ve been keeping a running ledger of what each chapter adds to the window since module 2; this chapter’s rows are the clearest “permanent cost” entries in it so far, because unlike a routing dial or a system-prompt file, there’s no cheap setting for a registered tool - its schema is in the window or it isn’t:

LayerWhat it costsWhat it buysWho decided
/stash commandNothing on a quiet turn - it only spends when you type itNo more hand-written SQL to find which entry is brokenyou
reextract_url toolA schema entry sent on every turn, called or not - measure it yourself the way the MCP section just showedThe model can fix a mangled entry mid-conversation, without the Python-shell danceyou
tool_call hook on stash.dbOne more mutable check Pi runs before every tool callA DROP TABLE against your only copy of the data gets refused instead of executedyou

Notice who’s in that last column every time. Not Pi, not the model - you, because you’re the one who wrote the file. That’s the trade this whole chapter has been making explicit: you can add capability now, and nothing stops you from adding more. Every one of those additions is permanent schema sitting in the window on every single turn, and nothing in Pi is checking whether it still earns that rent. The next chapter is the same question turned toward the gap you just felt writing that tool_call regex by hand: what’s stopping the model from running anything else it wants, and which of the guardrails you’d have to rebuild are actually worth their own turn-by-turn cost. Next: rebuild the defaults.