Course · Pi · Your first extension
Build a command: /stash
You already know this chore’s manual shape: open a terminal, write a one-off SELECT against stash.db (the database of stash, the course’s running example - the small FastAPI feed reader this module’s opener describes, with its backlog of rows saved while date parsing was broken), squint at which rows still carry a missing published or a blank summary. The backlog shrinks every time you fix a feed and grows again whenever a bad one slips through.
Finding which entries still need work is a job for a command you trigger yourself, on demand - not something you want the model deciding to run on your behalf mid-conversation. Slash commands are Pi’s answer to “user-initiated, not model-initiated,” and pi.registerCommand is how you add one.
Registering it
Section titled “Registering it”One frame before the code: an extension is a TypeScript file under .pi/extensions/ (or your global extensions folder) whose default-exported function receives Pi’s ExtensionAPI when Pi loads it - the same mechanism that carries tools and hooks, introduced properly in the anatomy lesson. That exported function is the factory; pi.registerCommand inside it is what adds a slash command. With that frame:
Inside your extension’s factory function:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
const FILTERS = ["undated", "unlinked", "blank"];
const QUERY = `import sqlite3, syswhere = { "undated": "published IS NULL OR published IN ('', 'None')", "unlinked": "link IS NULL OR link = ''", "blank": "summary IS NULL OR summary = ''",}[sys.argv[1]]rows = sqlite3.connect("stash.db").execute( "SELECT id, feed_id, title, link FROM articles WHERE " + where).fetchall()for r in rows: print(f"# {r[0]} feed {r[1]} {r[2]} {r[3] or '(no link)'}")`;
export default function (pi: ExtensionAPI) { pi.registerCommand("stash", { description: "Show stash entries that still need attention", getArgumentCompletions: (prefix) => [ { value: "undated", label: "undated - published missing or stored as 'None'" }, { value: "unlinked", label: "unlinked - link empty" }, { value: "blank", label: "blank - summary empty" }, ].filter((c) => c.value.startsWith(prefix)),
handler: async (args, ctx) => { const filter = args.trim() || "undated"; if (!FILTERS.includes(filter)) { ctx.ui.notify(`Unknown filter '${filter}' - try undated, unlinked, or blank.`, "error"); return; }
const result = await pi.exec("python3", ["-c", QUERY, filter], { signal: ctx.signal, timeout: 10_000, });
if (result.code !== 0) { ctx.ui.notify(`query failed: ${result.stderr}`, "error"); return; }
const rows = result.stdout.trim().split("\n").filter(Boolean); ctx.ui.notify( rows.length ? `${rows.length} ${filter} entries:\n${rows.join("\n")}` : `No ${filter} entries - you're clean.`, "info" ); }, });}Two assumptions make that pi.exec work, and both come from how the repo already runs. Pi’s session sits at the repo root, which is where stash.db lives - stash/db.py opens it by the bare relative path Path("stash.db"), so the working directory has to be right or the query hits a fresh empty database somewhere else.
And /stash needs only the standard library: sqlite3 ships with Python, so the python3 executable established in the course prerequisites is enough. The project’s venv doesn’t have to be active for this one - remember that, because the next lesson’s tool is pickier.
One clause in the query earns a second look. undated matches both NULL and the literal string 'None', and that’s not paranoia - db.py’s save_article stores str(article.published), and str(None) is 'None'. The rows saved while date parsing was still broken carry the string, not a null, so the filter has to catch both. That’s the kind of wart you only find by reading the writer, and it’s why the query lives in a file you can edit rather than a tool you were handed.
Save that, run /reload, and /stash is live. Before you type it, predict what you’ll see: the backlog was a few hundred rows at its worst and you’ve been working it down all week, so the undated list should come back a handful of rows long - not zero, not fifty. The shape of the reply, illustrative:
> /stash undated
2 undated entries: # 1 feed 1 Hello https://example.com/hello # 3 feed 2 Note https://notes.example.org/n1Two rows, exact entry IDs, feed IDs attached - the two things the fix needs. Hold onto feed 1; you’ll re-sync it in the next lesson.
Type /stash and pause before hitting enter, and the completions you registered show up as suggestions - undated, unlinked, blank - so you don’t have to remember the exact filter strings you invented.
One of the three filters will look broken the first time you run it, and it’s worth knowing why before you distrust the other two. /stash unlinked comes back with at most one row, ever (the writer stores link as a plain string, so empty means '' - which the unique index caps at one; a NULL would evade it, and this repo never writes one) - not a bug in the query.
The dedup fix from the context chapter put a unique index on articles.link, and the migration that made the index possible collapsed every duplicate link into its first row, empty links included. The schema itself already swept most of the unlinked class of the backlog. The filter stays because an empty link can still land - the index just makes it impossible for a second one to pile up behind it - and because the three completions read as the backlog’s definition.
What the handler actually receives
Section titled “What the handler actually receives”A few things about the signature are worth being precise about, because they’re easy to get wrong by analogy with the tool you’ll build next lesson:
nameis passed without the leading slash. You callpi.registerCommand("stash", ...); the user types/stash.argsis the raw remainder of the line, as one string - everything after/stash, unsplit./stash undatedhands your handler"undated"; there’s no automatic positional-argument parsing the way some other tools’ templating syntax works. If a command needs several arguments, splittingargsyourself is on you.- The handler gets
ExtensionCommandContext, not the plainExtensionContextyour hooks will see - a superset with session-control methods layered on (ctx.newSession,ctx.fork,ctx.switchSession,ctx.navigateTree,ctx.reload), which is the machinery behind Pi’s fork/tree/resume commands and, later in this course, subagent extensions. The docs note these live only on the command context because calling them from an event handler can deadlock the session. You won’t need any of that for/stash, but it’s there when you do. pi.exec, not something onctx- a top-level convenience for shelling out: pass a command, an argument array, and optionallysignalandtimeout, and it returns{ stdout, stderr, code, killed }with the turn’s abort signal already wired through (ctx.signalabove). Everything/stashdoes with the database goes through it.
A raw string in, a notification out - a command’s contract is the simplest one this chapter builds.
Where /stash sits relative to everything else typed at the prompt
Section titled “Where /stash sits relative to everything else typed at the prompt”A couple of ordering facts save you real confusion later:
- Extension commands come first in Pi’s command ordering.
pi.getCommands()lists extensions first, then prompt templates, then skills - so if you later build astashskill (the skills chapter) with the same name, it doesn’t shadow the command you registered. - Name collisions between extensions auto-suffix instead of erroring. If two loaded extensions both register a
stashcommand, Pi doesn’t crash or silently drop one - you get/stash:1and/stash:2, in load order. Worth knowing if you ever load someone else’s extension pack alongside your own. - Built-in commands are not the same mechanism with better names.
/settings,/model,/tree,/fork,/resume,/reloadare a separate, privileged tier: they never appear inpi.getCommands(), and they’re handled only in interactive mode - apromptsent over RPC can’t invoke them. The suffixing above is extension-versus-extension only; Pi’s own commands don’t compete in that list at all, and nothing you register displaces one.
/stash solves half the chore: finding what’s left. But it only ever runs when you type it - the model can’t reach for it mid-conversation, and it can’t act on what it finds without you copying a feed ID back into the chat. Clearing a feed’s backlog rows is exactly the kind of thing you’d rather hand to the agent directly. Next: give the model its own tool to do the re-sync.