Skip to content

The shape of a Pi extension

Before you write the /stash command or the re-extract tool, it’s worth seeing the skeleton every Pi extension shares - because once you’ve seen it, both of the lessons that follow are just “fill in this one function with something more specific.”

An extension is a single TypeScript file (or a folder with an index.ts) whose default export is a function that receives Pi’s extension API - conventionally named pi inside the file, which is a little confusing next to the pi CLI binary itself, but it’s how every example in the docs writes it, so we will too:

.pi/extensions/stash-tools.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
pi.on("session_start", async (_event, ctx) => {
ctx.ui.notify("stash-tools loaded", "info");
});
}

That’s a complete, working extension. It does almost nothing - it just proves it loaded - but every extension you’ll build this chapter is this same shape with more calls inside the function body: pi.registerCommand(...) for a slash command, pi.registerTool(...) for something the model can call, pi.on(...) for a lifecycle hook. The factory can also be async, which matters if an extension needs to do setup work - reading a config file, warming a cache - before the session continues; Pi will await it.

Top-level registration is the simplest pattern because the tool, command, or shortcut is available throughout startup. It is not a universal restriction, though: Pi also supports registering resources from event handlers when you intentionally want dynamic behavior. Use session_start for setup work, and make any later registration explicit about when it becomes available.

Everything above is real, and this chapter has you type it by hand - the /stash command, the re-extract tool, the hook that wires them together - because there’s no substitute for having built one yourself at least once. But once you’ve done that, it’s not how you’ll spend most of your time with Pi extensions. The common workflow, once you know the shape, is to describe the extension you want and let Pi write the file.

You’d say something like: “Write a Pi extension at .pi/extensions/stash-tools.ts that registers a tool called reextract_url - it takes an entry ID, looks up the saved URL in stash’s SQLite database, re-runs the fetch-and-extract pipeline against it, and replaces the mangled content with the fresh result.” Pi will produce a file that looks close to what you’ve seen in this lesson - a default-exported factory, a registerTool call with a schema, maybe a ctx.ui.notify on success.

The reason to have just read through the factory function, the registration pattern, and the discovery folders isn’t so you can recite them - it’s so you can look at what Pi hands back and catch the things that go wrong. A tool registered dynamically when you expected it during startup. A hook wired to session_start when the chore actually needs to run on every turn. A tool with no input schema, so the model can call it with anything. Knowing the shape is what lets you spot the mismatch instead of shrugging and re-describing the whole thing from scratch.

Once you’re comfortable with the pattern, extensions worth asking Pi for on stash start looking like this:

  • A /digest command that lists the week’s saved-but-unread entries, so you stop scrolling to find them.
  • A tool that flags likely duplicate saves - same URL, different tracking parameters - before they clutter the database.
  • A hook that runs a lightweight tag-suggestion pass on every new save, using the domain and title alone.
  • A shortcut that jumps straight to re-extracting whatever entry you’re currently looking at, without typing an ID.

So treat the rest of this chapter as calibration, not a script to memorize: read what Pi writes, /reload, test it against a real entry, and iterate - the same loop you’d use whether you typed the file yourself or Pi did.

Notice the file above is plain .ts - no dist/ folder, no tsconfig you have to wire up, nothing to run before Pi will pick it up. Pi loads extensions through jiti, a TypeScript runtime transpiler, so you write TypeScript and Pi transpiles it on load. Change the file, and the change is live the moment you reload - there’s no compile-and-restart cycle standing between an idea and it running.

If your extension needs an npm dependency beyond what Pi bundles, drop a package.json next to it and run npm install - imports resolve out of node_modules/ normally, and Node’s own built-ins (node:fs, node:path, node:child_process) work without any install at all. A handful of Pi’s own packages are importable without installing anything, because Pi bundles them for extensions to use directly: @earendil-works/pi-coding-agent (the types you’ll use constantly, like ExtensionAPI), typebox (the schema library registerTool expects), @earendil-works/pi-ai (includes a StringEnum helper you’ll need in the next lesson), and @earendil-works/pi-tui (components for building custom UI, if you go that far).

Extensions have a global home and a project-local one, and the split matters - a tool you build for stash specifically shouldn’t clutter every other project’s session:

GlobalProject-local
Extensions~/.pi/agent/extensions/.pi/extensions/

Both accept either a single *.ts file or a subdirectory containing an index.ts. For a chore that only makes sense inside stash - like re-extracting a saved URL - the project-local folder is the right home: .pi/extensions/stash-tools.ts.

There’s a catch on the project side worth knowing before you’re confused by an extension that silently doesn’t load: .pi/extensions/ is only scanned after the project is trusted. Trust is Pi’s one gate on “will I run code from this directory automatically,” and it exists for exactly the reason you’d guess - an extension is arbitrary TypeScript with full system permissions, the same reach as bash itself. Loading one from a project you don’t trust is functionally the same as running its code. If you cloned stash from somewhere and haven’t seen this project prompt trust you yet, that’s the first thing to check.

While you’re iterating, you don’t have to touch either folder at all - pi -e ./stash-tools.ts loads a specific file for just that run, which is the fastest way to try an idea before you’ve decided it’s worth keeping around permanently.

Once a file is saved in one of the discovery folders (or you’ve pointed at it with -e), running /reload inside your session picks up every change - extensions, skills, prompt templates, themes - without losing your conversation. Under the hood it fires session_shutdown on whatever was loaded, then session_start again with reason: "reload", so any cleanup or setup logic in those hooks runs exactly like it would on a fresh launch. You’ll use /reload after nearly every edit for the rest of this chapter - it’s the difference between “iterate on an extension” feeling like a five-second loop instead of a five-minute one.

You now know the shape every extension takes and the two places Pi will find one. Time to fill that shape in with something stash actually needs - starting with the one a human triggers on purpose. Next: build the /stash command.